From 9dd065aa1f0d67e3f9c669421175de1448b0405e Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 5 May 2024 21:09:41 +0800 Subject: [PATCH 01/42] Initial import --- .gitignore | 3 + .pre-commit-config.yaml | 11 +++ Pipfile | 14 +++ Pipfile.lock | 199 ++++++++++++++++++++++++++++++++++++++++ ruff.toml | 23 +++++ src/__init__.py | 0 src/data_loader.py | 93 +++++++++++++++++++ src/executor.py | 66 +++++++++++++ test/__init__.py | 0 test/test_main.py | 40 ++++++++ 10 files changed, 449 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 ruff.toml create mode 100644 src/__init__.py create mode 100644 src/data_loader.py create mode 100644 src/executor.py create mode 100644 test/__init__.py create mode 100644 test/test_main.py diff --git a/.gitignore b/.gitignore index 68bc17f..e1f665a 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Personal data +*.xlsx \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d542259 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.5.6 + hooks: + # Run the linter. + - id: ruff + args: ["--fix"] + # Run the formatter. + - id: ruff-format + diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..46e828a --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +openpyxl = "*" +sqlalchemy = "*" +pytest = "*" + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..dccbdb6 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,199 @@ +{ + "_meta": { + "hash": { + "sha256": "daa478125c820ad0f3b1ab145559288de25217a1befe2a7c907943885fb3177b" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.12" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "et-xmlfile": { + "hashes": [ + "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", + "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + ], + "markers": "python_version >= '3.6'", + "version": "==1.1.0" + }, + "greenlet": { + "hashes": [ + "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", + "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6", + "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257", + "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4", + "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", + "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61", + "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", + "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca", + "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7", + "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728", + "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", + "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", + "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379", + "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414", + "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04", + "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a", + "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf", + "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", + "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559", + "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e", + "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274", + "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb", + "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b", + "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9", + "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b", + "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", + "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506", + "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405", + "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113", + "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f", + "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5", + "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", + "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d", + "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f", + "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a", + "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e", + "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61", + "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6", + "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d", + "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71", + "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22", + "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", + "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3", + "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067", + "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc", + "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881", + "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3", + "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", + "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac", + "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53", + "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0", + "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b", + "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83", + "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41", + "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c", + "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", + "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da", + "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33" + ], + "markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==3.0.3" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "openpyxl": { + "hashes": [ + "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", + "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.1.2" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "pluggy": { + "hashes": [ + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "pytest": { + "hashes": [ + "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233", + "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==8.2.0" + }, + "sqlalchemy": { + "hashes": [ + "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb", + "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c", + "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d", + "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a", + "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003", + "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699", + "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e", + "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93", + "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de", + "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513", + "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380", + "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567", + "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586", + "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b", + "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673", + "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d", + "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b", + "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e", + "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c", + "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03", + "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e", + "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec", + "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72", + "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c", + "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41", + "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0", + "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba", + "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b", + "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930", + "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7", + "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1", + "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1", + "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9", + "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c", + "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f", + "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520", + "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b", + "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0", + "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552", + "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907", + "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e", + "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f", + "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5", + "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305", + "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01", + "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44", + "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd", + "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5", + "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.0.29" + }, + "typing-extensions": { + "hashes": [ + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + ], + "markers": "python_version >= '3.8'", + "version": "==4.11.0" + } + }, + "develop": {} +} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..05a94a9 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,23 @@ +# Set the maximum line length to 79. +line-length = 79 + +[lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] + +[format] +docstring-code-format = true +docstring-code-line-length = 72 + diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/data_loader.py b/src/data_loader.py new file mode 100644 index 0000000..5f47bc2 --- /dev/null +++ b/src/data_loader.py @@ -0,0 +1,93 @@ +"""XML data loader.""" + +import datetime +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import openpyxl + + +@dataclass +class Summons: + summons_account: str + date: datetime.date + amount: int + + +class AbstractDataLoader(ABC): + """ + An abstract data loader. + + Attributes: + targets (List[Summons]): The list of targets that we want to + solve as subset sum target. + numbers (List[Summons]): The subset that we search for the sum + of its subset is equal to target. + """ + + targets: list[Summons] + numbers: list[Summons] + _loaded: bool + + def __init__(self): + self._loaded = False + + @property + def loaded(self): + """ + Check data is loaded or not. + + If data is loaded, return True. Else, return false. + """ + return self._loaded + + @abstractmethod + def load(self): + """Load data.""" + pass + + def sort(self): + """Sort numbers and targets""" + if self.loaded: + self.numbers.sort(key=lambda x: (x.date, x.amount)) + self.targets.sort(key=lambda x: (x.date, x.amount)) + + +class ExcelDataLoader(AbstractDataLoader): + """A Data Loader load data from Excel.""" + + def __init__(self): + super().__init__() + + def load(self, filename="normal.xlsx", reload=False): + if self.loaded and not reload: + return + workbook = openpyxl.load_workbook(filename) + sheet = workbook.active + start_header = "VOUCHER#" + for row in sheet.iter_rows(): + if row[0].value == start_header: + headers = [col.value for col in row] + start_row = row[0].row + 1 + break + else: + raise ValueError("Header NOT FOUND!!") + self.targets = [] + self.numbers = [] + for row in sheet.iter_rows(min_row=start_row, values_only=True): + data = {key: value for key, value in zip(headers, row)} + if data[start_header]: + summons_account = data[start_header] + amount = data["VOUCHER_AMT"] + year = int(summons_account[:4]) + month = int(summons_account[4:6]) + day = int(summons_account[6:8]) + date = datetime.date(year, month, day) + summons = Summons(summons_account, date, abs(amount)) + flag = data["D/C"] + if flag == "D": + self.targets.append(summons) + else: + self.numbers.append(summons) + self._loaded = True + self.sort() diff --git a/src/executor.py b/src/executor.py new file mode 100644 index 0000000..93adbbb --- /dev/null +++ b/src/executor.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from itertools import combinations +from typing import Optional + +from src.data_loader import AbstractDataLoader, ExcelDataLoader, Summons + + +@dataclass +class Result: + """ + Represents the result of a subset sum problem. + + Attributes: + target (Summons): The target of subset sum problem. + subset (Optional[List[Summons]]): The subset of Summons that + sums up to the target. If no such subset exists, it is + None. + """ + + target: Summons + subset: Optional[list[Summons]] + + +class AbstractExecutor(ABC): + data_loader: AbstractDataLoader + + @abstractmethod + def _calculate(self, target: Summons): + """Calculate a subset sum.""" + + @abstractmethod + def calculate_all(self): + """Calculate all subset sum.""" + + +class BruteForceExecutor(AbstractExecutor): + def __init__(self, data_loader: AbstractDataLoader = ExcelDataLoader): + """Initialize data loader and use it to load data.""" + self.data_loader = data_loader() + self.data_loader.load() + + def _calculate(self, target: Summons): + """Find subset sum that is equal to target.""" + numbers = [ + i for i in self.data_loader.numbers if i.amount <= target.amount + ] + cal = 0 + for r in range(1, len(numbers)): + for combination in combinations(numbers, r): + if sum([i.amount for i in combination]) == target.amount: + return Result(target, combination) + cal = cal + 1 + return Result(target, None) + + def calculate_all(self) -> list[Result]: + results = [] + count = 0 + for target in self.data_loader.targets: + result = self._calculate(target) + results.append(result) + if result.subset: + for i in result.subset: + self.data_loader.numbers.remove(i) + count = count + 1 + return results diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..65496bd --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,40 @@ +import datetime + +from src.data_loader import AbstractDataLoader, Summons +from src.executor import BruteForceExecutor + +numbers = [i for i in range(23)] +targets = [sum(numbers) + 1, numbers[-1]] + + +class FakeDataLoader(AbstractDataLoader): + """A Fake Data Loader.""" + + def __init__(self): + super().__init__() + self.targets = [ + Summons("targets", datetime.date(2020, 1, 1), target) + for target in targets + ] + self.numbers = [ + Summons("numbers", datetime.date(2020, 1, 1), number) + for number in numbers + ] + self.sort() + self._loaded = True + + def load(self): + pass + + +def cal(cls): + eva = cls(FakeDataLoader) + assert eva.data_loader.loaded + results = eva.calculate_all() + for i in results: + if i.subset: + assert sum([x.amount for x in i.subset]) == i.target.amount + + +def test_main(): + cal(BruteForceExecutor) From 67ebff876e9740d3e4954f8a4aaf5494fb6af23d Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 17 Aug 2024 20:59:46 +0800 Subject: [PATCH 02/42] Add periodic logging to BruteForceExecutor Implemented a logging feature in BruteForceExecutor to log the current status at specified intervals. --- src/executor.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/executor.py b/src/executor.py index 93adbbb..5a18799 100644 --- a/src/executor.py +++ b/src/executor.py @@ -1,3 +1,5 @@ +import logging +import time from abc import ABC, abstractmethod from dataclasses import dataclass from itertools import combinations @@ -5,6 +7,13 @@ from src.data_loader import AbstractDataLoader, ExcelDataLoader, Summons +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - [%(levelname)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + @dataclass class Result: @@ -40,27 +49,44 @@ def __init__(self, data_loader: AbstractDataLoader = ExcelDataLoader): self.data_loader = data_loader() self.data_loader.load() - def _calculate(self, target: Summons): + def _calculate(self, target: Summons, interval_sec: int): """Find subset sum that is equal to target.""" numbers = [ i for i in self.data_loader.numbers if i.amount <= target.amount ] - cal = 0 + total_calculation = 2 ** len(numbers) + already_calculation = 0 + start_time = time.time() for r in range(1, len(numbers)): for combination in combinations(numbers, r): if sum([i.amount for i in combination]) == target.amount: + logger.debug( + f"Target {target.amount}: " + f"{tuple(i.amount for i in combination)}" + ) return Result(target, combination) - cal = cal + 1 + already_calculation += 1 + current_time = time.time() + if current_time - start_time > interval_sec: + logger.info( + f"Calculating {target.amount}, " + f"{already_calculation/total_calculation*100:.2f}%" + ) + start_time = time.time() + logger.debug(f"Target {target.amount}: NO result.") return Result(target, None) - def calculate_all(self) -> list[Result]: + def calculate_all(self, interval_sec: int = 1) -> list[Result]: results = [] count = 0 + start_time = time.time() for target in self.data_loader.targets: - result = self._calculate(target) + result = self._calculate(target, interval_sec) results.append(result) if result.subset: for i in result.subset: self.data_loader.numbers.remove(i) count = count + 1 + elpased_time = time.time() - start_time + logger.info(f"elpased time: {elpased_time:.3f} seconds.") return results From 8ca9b0db8220d103d81ff759d0e5fe25c8839aab Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 31 Aug 2024 10:47:08 +0800 Subject: [PATCH 03/42] Add an interval argument to calculation function After this commit, we can adjust interval time easily. --- src/executor.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/executor.py b/src/executor.py index 5a18799..d7a0ff8 100644 --- a/src/executor.py +++ b/src/executor.py @@ -14,6 +14,8 @@ datefmt="%Y-%m-%d %H:%M:%S", ) +DEFAULT_INTERVAL = 3 + @dataclass class Result: @@ -35,11 +37,9 @@ class AbstractExecutor(ABC): data_loader: AbstractDataLoader @abstractmethod - def _calculate(self, target: Summons): - """Calculate a subset sum.""" - - @abstractmethod - def calculate_all(self): + def calculate_all( + self, interval_sec: int = DEFAULT_INTERVAL + ) -> list[Result]: """Calculate all subset sum.""" @@ -49,7 +49,7 @@ def __init__(self, data_loader: AbstractDataLoader = ExcelDataLoader): self.data_loader = data_loader() self.data_loader.load() - def _calculate(self, target: Summons, interval_sec: int): + def _calculate(self, target: Summons, interval_sec: int) -> Result: """Find subset sum that is equal to target.""" numbers = [ i for i in self.data_loader.numbers if i.amount <= target.amount @@ -76,17 +76,26 @@ def _calculate(self, target: Summons, interval_sec: int): logger.debug(f"Target {target.amount}: NO result.") return Result(target, None) - def calculate_all(self, interval_sec: int = 1) -> list[Result]: + def calculate_all( + self, interval_sec: int = DEFAULT_INTERVAL + ) -> list[Result]: + """Calculate all subset sum.""" results = [] count = 0 start_time = time.time() for target in self.data_loader.targets: + start_time = time.time() result = self._calculate(target, interval_sec) + end_time = time.time() + logger.info( + f"Target: {target.amount}, " + f"elpased time: {end_time - start_time:.3f} seconds." + ) results.append(result) if result.subset: for i in result.subset: self.data_loader.numbers.remove(i) count = count + 1 elpased_time = time.time() - start_time - logger.info(f"elpased time: {elpased_time:.3f} seconds.") + logger.info(f"Total elpased time: {elpased_time:.3f} " "seconds.") return results From db7a27068ab88dc7f74719cf2d77b62d381f0426 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 31 Aug 2024 10:57:03 +0800 Subject: [PATCH 04/42] Remove colon in logging format --- src/executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/executor.py b/src/executor.py index d7a0ff8..4201d69 100644 --- a/src/executor.py +++ b/src/executor.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) logging.basicConfig( level=logging.DEBUG, - format="%(asctime)s - [%(levelname)s]: %(message)s", + format="%(asctime)s - [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) From b9add76fe527ae1e7e6a5aa256a9989db6fb6c28 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 31 Aug 2024 21:36:31 +0800 Subject: [PATCH 05/42] Adjust global variable scope Change variable logger to private varibale because we don't want user to change it. --- src/executor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/executor.py b/src/executor.py index 4201d69..ae10413 100644 --- a/src/executor.py +++ b/src/executor.py @@ -7,7 +7,7 @@ from src.data_loader import AbstractDataLoader, ExcelDataLoader, Summons -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - [%(levelname)s] %(message)s", @@ -60,7 +60,7 @@ def _calculate(self, target: Summons, interval_sec: int) -> Result: for r in range(1, len(numbers)): for combination in combinations(numbers, r): if sum([i.amount for i in combination]) == target.amount: - logger.debug( + _logger.debug( f"Target {target.amount}: " f"{tuple(i.amount for i in combination)}" ) @@ -68,12 +68,12 @@ def _calculate(self, target: Summons, interval_sec: int) -> Result: already_calculation += 1 current_time = time.time() if current_time - start_time > interval_sec: - logger.info( + _logger.info( f"Calculating {target.amount}, " f"{already_calculation/total_calculation*100:.2f}%" ) start_time = time.time() - logger.debug(f"Target {target.amount}: NO result.") + _logger.debug(f"Target {target.amount}: NO result.") return Result(target, None) def calculate_all( @@ -87,7 +87,7 @@ def calculate_all( start_time = time.time() result = self._calculate(target, interval_sec) end_time = time.time() - logger.info( + _logger.info( f"Target: {target.amount}, " f"elpased time: {end_time - start_time:.3f} seconds." ) @@ -97,5 +97,5 @@ def calculate_all( self.data_loader.numbers.remove(i) count = count + 1 elpased_time = time.time() - start_time - logger.info(f"Total elpased time: {elpased_time:.3f} " "seconds.") + _logger.info(f"Total elpased time: {elpased_time:.3f} " "seconds.") return results From fa6e17494bab7d6026e951b55f75e0aeae62c296 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 1 Sep 2024 15:16:16 +0800 Subject: [PATCH 06/42] Merge duplicate method and rename variable - Merged the calculate_all method from BruteForceExecutor and MultiProcessExecutor to eliminate duplication, as the functionality is identical in both classes. - Renamed variable to improve accuracy in measurement terminology. --- src/executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/executor.py b/src/executor.py index ae10413..f0dc30a 100644 --- a/src/executor.py +++ b/src/executor.py @@ -82,7 +82,7 @@ def calculate_all( """Calculate all subset sum.""" results = [] count = 0 - start_time = time.time() + overall_start_time = time.time() for target in self.data_loader.targets: start_time = time.time() result = self._calculate(target, interval_sec) @@ -96,6 +96,6 @@ def calculate_all( for i in result.subset: self.data_loader.numbers.remove(i) count = count + 1 - elpased_time = time.time() - start_time + elpased_time = time.time() - overall_start_time _logger.info(f"Total elpased time: {elpased_time:.3f} " "seconds.") return results From 5eb62ca8744ebf36890068120dd6040d07fc0334 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 1 Sep 2024 15:20:59 +0800 Subject: [PATCH 07/42] Fix typo --- src/executor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/executor.py b/src/executor.py index f0dc30a..0a6a797 100644 --- a/src/executor.py +++ b/src/executor.py @@ -89,13 +89,13 @@ def calculate_all( end_time = time.time() _logger.info( f"Target: {target.amount}, " - f"elpased time: {end_time - start_time:.3f} seconds." + f"elapsed time: {end_time - start_time:.3f} seconds." ) results.append(result) if result.subset: for i in result.subset: self.data_loader.numbers.remove(i) count = count + 1 - elpased_time = time.time() - overall_start_time - _logger.info(f"Total elpased time: {elpased_time:.3f} " "seconds.") + elapsed_time = time.time() - overall_start_time + _logger.info(f"Total elapsed time: {elapsed_time:.3f} " "seconds.") return results From d97cb6819b35763b2454bc5636508d15047a6865 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 2 Nov 2024 04:43:57 +0800 Subject: [PATCH 08/42] Change the way to load data Because the format of excel is changed, so we need to change the way to load data correctly. --- src/data_loader.py | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/data_loader.py b/src/data_loader.py index 5f47bc2..2fa4be7 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -63,31 +63,22 @@ def load(self, filename="normal.xlsx", reload=False): if self.loaded and not reload: return workbook = openpyxl.load_workbook(filename) - sheet = workbook.active - start_header = "VOUCHER#" - for row in sheet.iter_rows(): - if row[0].value == start_header: - headers = [col.value for col in row] - start_row = row[0].row + 1 - break - else: - raise ValueError("Header NOT FOUND!!") + sheet = workbook[workbook.sheetnames[0]] + targets = [ + row[0] for row in sheet.iter_rows(values_only=True) if row[0] + ] self.targets = [] self.numbers = [] - for row in sheet.iter_rows(min_row=start_row, values_only=True): - data = {key: value for key, value in zip(headers, row)} - if data[start_header]: - summons_account = data[start_header] - amount = data["VOUCHER_AMT"] - year = int(summons_account[:4]) - month = int(summons_account[4:6]) - day = int(summons_account[6:8]) - date = datetime.date(year, month, day) - summons = Summons(summons_account, date, abs(amount)) - flag = data["D/C"] - if flag == "D": - self.targets.append(summons) - else: - self.numbers.append(summons) + sheet = workbook[workbook.sheetnames[1]] + for row in sheet.iter_rows(values_only=True): + tag = row[0] + date = datetime.date.fromisoformat(tag.split("-")[0]) + amount = abs(row[1]) + obj = Summons(tag, date, amount) + if amount in targets: + self.targets.append(obj) + targets.remove(amount) + else: + self.numbers.append(obj) self._loaded = True self.sort() From f9055b9179c6932cceec6e098b14b7bbb4cdead0 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 2 Nov 2024 04:46:37 +0800 Subject: [PATCH 09/42] Add filename argument for easier customization This update introduces a filename argument, allowing users to specify a custom filename more conveniently. --- src/executor.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/executor.py b/src/executor.py index 0a6a797..b292086 100644 --- a/src/executor.py +++ b/src/executor.py @@ -44,10 +44,18 @@ def calculate_all( class BruteForceExecutor(AbstractExecutor): - def __init__(self, data_loader: AbstractDataLoader = ExcelDataLoader): + + def __init__( + self, + data_loader: AbstractDataLoader = ExcelDataLoader, + filename=None, + ): """Initialize data loader and use it to load data.""" self.data_loader = data_loader() - self.data_loader.load() + if filename: + self.data_loader.load(filename) + else: + self.data_loader.load() def _calculate(self, target: Summons, interval_sec: int) -> Result: """Find subset sum that is equal to target.""" From 90a906c4cc6f1229ad9acca35c6574e5fe711fd0 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 2 Nov 2024 05:07:41 +0800 Subject: [PATCH 10/42] Remove redundant init method --- src/data_loader.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/data_loader.py b/src/data_loader.py index 2fa4be7..8cad9d5 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -56,9 +56,6 @@ def sort(self): class ExcelDataLoader(AbstractDataLoader): """A Data Loader load data from Excel.""" - def __init__(self): - super().__init__() - def load(self, filename="normal.xlsx", reload=False): if self.loaded and not reload: return From 120fb6473d019d53c5fb087e7afa1140aa3bee5f Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 2 Nov 2024 15:12:30 +0800 Subject: [PATCH 11/42] Remove redundant type hint --- src/executor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/executor.py b/src/executor.py index b292086..a3c3907 100644 --- a/src/executor.py +++ b/src/executor.py @@ -34,7 +34,6 @@ class Result: class AbstractExecutor(ABC): - data_loader: AbstractDataLoader @abstractmethod def calculate_all( From 671ee86573c9364561ed02be02b90c2c504af83e Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 2 Nov 2024 15:41:40 +0800 Subject: [PATCH 12/42] Update type hint and docstring --- src/data_loader.py | 26 +++++++++++++++++++++----- src/executor.py | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/data_loader.py b/src/data_loader.py index 8cad9d5..ae89947 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -1,4 +1,4 @@ -"""XML data loader.""" +"""This module contains data loader that use to load data.""" import datetime from abc import ABC, abstractmethod @@ -9,7 +9,16 @@ @dataclass class Summons: - summons_account: str + """ + An Accounting summons. + + Attributes: + account: The account of summon. + date: The date of summon. + amount: The amount of summon. + """ + + account: str date: datetime.date amount: int @@ -19,10 +28,11 @@ class AbstractDataLoader(ABC): An abstract data loader. Attributes: - targets (List[Summons]): The list of targets that we want to + targets: The list of targets that we want to solve as subset sum target. - numbers (List[Summons]): The subset that we search for the sum + numbers: The subset that we search for the sum of its subset is equal to target. + _loaded: The flag indicate that data is loaded or not. """ targets: list[Summons] @@ -30,6 +40,7 @@ class AbstractDataLoader(ABC): _loaded: bool def __init__(self): + """Initialize flag.""" self._loaded = False @property @@ -56,7 +67,12 @@ def sort(self): class ExcelDataLoader(AbstractDataLoader): """A Data Loader load data from Excel.""" - def load(self, filename="normal.xlsx", reload=False): + def load(self, filename: str = "normal.xlsx", reload=False): + """Load data from Excel. + + Parameters: + filename: The file path of excel. + """ if self.loaded and not reload: return workbook = openpyxl.load_workbook(filename) diff --git a/src/executor.py b/src/executor.py index a3c3907..d7fc76d 100644 --- a/src/executor.py +++ b/src/executor.py @@ -1,3 +1,5 @@ +"""This module contains executors that solve problems.""" + import logging import time from abc import ABC, abstractmethod @@ -23,8 +25,8 @@ class Result: Represents the result of a subset sum problem. Attributes: - target (Summons): The target of subset sum problem. - subset (Optional[List[Summons]]): The subset of Summons that + target: The target of subset sum problem. + subset: The subset of Summons that sums up to the target. If no such subset exists, it is None. """ @@ -34,6 +36,9 @@ class Result: class AbstractExecutor(ABC): + """ + Abstract executor class. + """ @abstractmethod def calculate_all( @@ -43,21 +48,35 @@ def calculate_all( class BruteForceExecutor(AbstractExecutor): + """ + Executor that use brute-force to solve problem. + """ def __init__( self, data_loader: AbstractDataLoader = ExcelDataLoader, - filename=None, + filename: str = None, ): - """Initialize data loader and use it to load data.""" - self.data_loader = data_loader() + """Initialize data loader and use it to load data. + + Parameters: + data_loader: The data loader use to load data. + filename: The file path. + """ + self.data_loader: AbstractDataLoader = data_loader() if filename: self.data_loader.load(filename) else: self.data_loader.load() def _calculate(self, target: Summons, interval_sec: int) -> Result: - """Find subset sum that is equal to target.""" + """Find subset sum that is equal to target. + + Parameters: + target: The target we want to calculate. + interval_sec: The time interval (in seconds) for updating + the status. + """ numbers = [ i for i in self.data_loader.numbers if i.amount <= target.amount ] @@ -86,7 +105,12 @@ def _calculate(self, target: Summons, interval_sec: int) -> Result: def calculate_all( self, interval_sec: int = DEFAULT_INTERVAL ) -> list[Result]: - """Calculate all subset sum.""" + """Calculate all subset sum. + + Parameters: + interval_sec: The time interval (in seconds) for updating + the status. + """ results = [] count = 0 overall_start_time = time.time() From 4e90bf26d95a953c6a4cdb34421f6007d1d12320 Mon Sep 17 00:00:00 2001 From: komark06 Date: Tue, 5 Nov 2024 00:24:50 +0800 Subject: [PATCH 13/42] Improve type hint and docstring --- src/executor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/executor.py b/src/executor.py index d7fc76d..a3c5482 100644 --- a/src/executor.py +++ b/src/executor.py @@ -37,9 +37,14 @@ class Result: class AbstractExecutor(ABC): """ - Abstract executor class. + Abstract executor class that solve subset sum problem. + + Attributes: + data_loader: The data loader the load data. """ + data_loader: AbstractDataLoader + @abstractmethod def calculate_all( self, interval_sec: int = DEFAULT_INTERVAL @@ -49,7 +54,7 @@ def calculate_all( class BruteForceExecutor(AbstractExecutor): """ - Executor that use brute-force to solve problem. + Executor that use brute-force to solve subset sum problem. """ def __init__( From f17b9799698eeccde03cd0d1d78c4676abb3bcdc Mon Sep 17 00:00:00 2001 From: komark06 Date: Tue, 5 Nov 2024 00:36:46 +0800 Subject: [PATCH 14/42] Refactor calculate_all to prevent side effects Updated BruteForceExecutor to maintain its own multiset, ensuring that the multiset in data_loader remains unchanged. This avoids unintended side effects during calculations. --- src/executor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/executor.py b/src/executor.py index a3c5482..3a9d92e 100644 --- a/src/executor.py +++ b/src/executor.py @@ -73,6 +73,7 @@ def __init__( self.data_loader.load(filename) else: self.data_loader.load() + self._numbers = list(self.data_loader.numbers) def _calculate(self, target: Summons, interval_sec: int) -> Result: """Find subset sum that is equal to target. @@ -82,9 +83,7 @@ def _calculate(self, target: Summons, interval_sec: int) -> Result: interval_sec: The time interval (in seconds) for updating the status. """ - numbers = [ - i for i in self.data_loader.numbers if i.amount <= target.amount - ] + numbers = [i for i in self._numbers if i.amount <= target.amount] total_calculation = 2 ** len(numbers) already_calculation = 0 start_time = time.time() @@ -130,8 +129,9 @@ def calculate_all( results.append(result) if result.subset: for i in result.subset: - self.data_loader.numbers.remove(i) + self._numbers.remove(i) count = count + 1 + self._numbers = list(self.data_loader.numbers) elapsed_time = time.time() - overall_start_time _logger.info(f"Total elapsed time: {elapsed_time:.3f} " "seconds.") return results From cce054b15e6406f65a645a9eea605befe642ed41 Mon Sep 17 00:00:00 2001 From: komark06 Date: Tue, 5 Nov 2024 10:59:35 +0800 Subject: [PATCH 15/42] Remove default argument for filename The filename should now be provided by the user instead of being set by the data_loader. This change enforces user-defined filenames for greater clarity and flexibility. --- src/data_loader.py | 2 +- src/executor.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/data_loader.py b/src/data_loader.py index ae89947..73b1aa5 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -67,7 +67,7 @@ def sort(self): class ExcelDataLoader(AbstractDataLoader): """A Data Loader load data from Excel.""" - def load(self, filename: str = "normal.xlsx", reload=False): + def load(self, filename: str, reload=False): """Load data from Excel. Parameters: diff --git a/src/executor.py b/src/executor.py index 3a9d92e..c9bd8ce 100644 --- a/src/executor.py +++ b/src/executor.py @@ -59,8 +59,8 @@ class BruteForceExecutor(AbstractExecutor): def __init__( self, - data_loader: AbstractDataLoader = ExcelDataLoader, filename: str = None, + data_loader: AbstractDataLoader = ExcelDataLoader, ): """Initialize data loader and use it to load data. @@ -69,10 +69,7 @@ def __init__( filename: The file path. """ self.data_loader: AbstractDataLoader = data_loader() - if filename: - self.data_loader.load(filename) - else: - self.data_loader.load() + self.data_loader.load(filename) self._numbers = list(self.data_loader.numbers) def _calculate(self, target: Summons, interval_sec: int) -> Result: From 813d288d2e734173ec290aa26ed5efac9adcb3a0 Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 13 Nov 2024 17:40:01 +0800 Subject: [PATCH 16/42] Improve docstring of load method --- src/data_loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data_loader.py b/src/data_loader.py index 73b1aa5..ff6d062 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -72,6 +72,7 @@ def load(self, filename: str, reload=False): Parameters: filename: The file path of excel. + reload: The flag to decide reload or not. """ if self.loaded and not reload: return From f5d6c3cc86e9106192bb267fb3e81fb9bb5ff110 Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 13 Nov 2024 17:47:02 +0800 Subject: [PATCH 17/42] Split test files based on functionality Reorganized test files into separate parts according to their functionality, improving test structure and maintainability. --- test/test_data_loader.py | 59 +++++++++++++++++++++++++ test/{test_main.py => test_executor.py} | 25 ++++------- 2 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 test/test_data_loader.py rename test/{test_main.py => test_executor.py} (62%) diff --git a/test/test_data_loader.py b/test/test_data_loader.py new file mode 100644 index 0000000..cc56611 --- /dev/null +++ b/test/test_data_loader.py @@ -0,0 +1,59 @@ +from openpyxl import Workbook +from src.data_loader import ExcelDataLoader, Summons +from datetime import date +from itertools import chain +from io import BytesIO + + +def test_load(): + """Verify that ExcelDataLoader successfully loads data from an file. + + Create a virtual excel and use ExcelDataLoader to load. + """ + targets = [ + Summons( + account="20240422-5259-000069", + date=date(2024, 4, 22), + amount=10, + ) + ] + numbers = [ + Summons( + account="20240411-5256-000107", + date=date(2024, 4, 11), + amount=3, + ), + Summons( + account="20240411-5257-000014", + date=date(2024, 4, 11), + amount=4, + ), + Summons( + account="20240411-5256-000094", + date=date(2024, 4, 11), + amount=5, + ), + Summons( + account="20240411-5257-000101", + date=date(2024, 4, 11), + amount=5, + ), + ] + workbook = Workbook() + sheet = workbook.active + for i, target in enumerate(targets): + sheet.cell(i + 1, 1, target.amount) + for i, number in enumerate(numbers): + sheet.cell(i + 1, 2, number.amount) + workbook.create_sheet("worksheet 2") + sheet = workbook["worksheet 2"] + for i, summon in enumerate(chain(targets, numbers)): + sheet.cell(i + 1, 1, summon.account) + sheet.cell(i + 1, 2, summon.amount) + with BytesIO() as file: + workbook.save(file) + data_loader = ExcelDataLoader() + data_loader.load(file) + assert targets == data_loader.targets + assert numbers == data_loader.numbers + assert data_loader._loaded diff --git a/test/test_main.py b/test/test_executor.py similarity index 62% rename from test/test_main.py rename to test/test_executor.py index 65496bd..ddfe160 100644 --- a/test/test_main.py +++ b/test/test_executor.py @@ -1,17 +1,16 @@ import datetime - from src.data_loader import AbstractDataLoader, Summons from src.executor import BruteForceExecutor -numbers = [i for i in range(23)] -targets = [sum(numbers) + 1, numbers[-1]] - class FakeDataLoader(AbstractDataLoader): - """A Fake Data Loader.""" + """A Fake Data Loader that simulate ExcelDataLoader.""" + + def load(self, filename: str, reload=False): + """Simulate the load method of ExcelDataLoader.""" + numbers = [i for i in range(23)] + targets = [sum(numbers) + 1, numbers[-1]] - def __init__(self): - super().__init__() self.targets = [ Summons("targets", datetime.date(2020, 1, 1), target) for target in targets @@ -23,18 +22,12 @@ def __init__(self): self.sort() self._loaded = True - def load(self): - pass - -def cal(cls): - eva = cls(FakeDataLoader) +def test_executor(): + """Verify that BruteForceExecutor successfully solve problem.""" + eva = BruteForceExecutor(data_loader=FakeDataLoader) assert eva.data_loader.loaded results = eva.calculate_all() for i in results: if i.subset: assert sum([x.amount for x in i.subset]) == i.target.amount - - -def test_main(): - cal(BruteForceExecutor) From 4c62541c8bb8ece97bcffb51ff1fe1cad0f6b038 Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 13 Nov 2024 17:50:19 +0800 Subject: [PATCH 18/42] Remove sqlalchemy from Pipfile --- Pipfile | 1 - Pipfile.lock | 164 +++++++-------------------------------------------- 2 files changed, 22 insertions(+), 143 deletions(-) diff --git a/Pipfile b/Pipfile index 46e828a..11b80a0 100644 --- a/Pipfile +++ b/Pipfile @@ -5,7 +5,6 @@ name = "pypi" [packages] openpyxl = "*" -sqlalchemy = "*" pytest = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index dccbdb6..cfac77c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "daa478125c820ad0f3b1ab145559288de25217a1befe2a7c907943885fb3177b" + "sha256": "e47386a5ae091a1b2377cb5d10046d9584f7cd856a311f3c24e0614d4f08904c" }, "pipfile-spec": 6, "requires": { @@ -16,77 +16,21 @@ ] }, "default": { - "et-xmlfile": { + "colorama": { "hashes": [ - "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", - "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], - "markers": "python_version >= '3.6'", - "version": "==1.1.0" + "markers": "sys_platform == 'win32'", + "version": "==0.4.6" }, - "greenlet": { + "et-xmlfile": { "hashes": [ - "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", - "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6", - "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257", - "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4", - "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", - "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61", - "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", - "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca", - "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7", - "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728", - "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", - "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", - "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379", - "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414", - "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04", - "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a", - "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf", - "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", - "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559", - "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e", - "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274", - "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb", - "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b", - "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9", - "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b", - "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", - "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506", - "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405", - "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113", - "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f", - "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5", - "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", - "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d", - "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f", - "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a", - "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e", - "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61", - "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6", - "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d", - "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71", - "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22", - "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", - "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3", - "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067", - "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc", - "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881", - "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3", - "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", - "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac", - "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53", - "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0", - "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b", - "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83", - "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41", - "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c", - "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", - "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da", - "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33" + "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", + "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54" ], - "markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", - "version": "==3.0.3" + "markers": "python_version >= '3.8'", + "version": "==2.0.0" }, "iniconfig": { "hashes": [ @@ -98,20 +42,20 @@ }, "openpyxl": { "hashes": [ - "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", - "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" + "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", + "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==3.1.2" + "markers": "python_version >= '3.8'", + "version": "==3.1.5" }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.2" }, "pluggy": { "hashes": [ @@ -123,76 +67,12 @@ }, "pytest": { "hashes": [ - "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233", - "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==8.2.0" - }, - "sqlalchemy": { - "hashes": [ - "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb", - "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c", - "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d", - "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a", - "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003", - "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699", - "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e", - "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93", - "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de", - "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513", - "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380", - "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567", - "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586", - "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b", - "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673", - "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d", - "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b", - "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e", - "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c", - "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03", - "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e", - "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec", - "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72", - "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c", - "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41", - "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0", - "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba", - "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b", - "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930", - "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7", - "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1", - "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1", - "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9", - "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c", - "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f", - "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520", - "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b", - "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0", - "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552", - "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907", - "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e", - "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f", - "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5", - "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305", - "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01", - "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44", - "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd", - "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5", - "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758" + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.0.29" - }, - "typing-extensions": { - "hashes": [ - "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", - "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" - ], "markers": "python_version >= '3.8'", - "version": "==4.11.0" + "version": "==8.3.3" } }, "develop": {} From f181e34d2b959f2744e8bd0013abfa94899fdd9a Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 17 Nov 2024 19:49:19 +0800 Subject: [PATCH 19/42] Update executor to decouple data loading The executor now focuses solely on result calculation. Data loading functionality has been removed, requiring users to pass data directly into the executor. This change improves separation of concerns and reduces coupling. --- src/executor.py | 56 ++++++++++++++++++++++++------------------- test/test_executor.py | 9 +++---- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/executor.py b/src/executor.py index c9bd8ce..5c92764 100644 --- a/src/executor.py +++ b/src/executor.py @@ -47,9 +47,20 @@ class AbstractExecutor(ABC): @abstractmethod def calculate_all( - self, interval_sec: int = DEFAULT_INTERVAL + self, + targets: list[Summons], + numbers: list[Summons], + interval_sec: int = DEFAULT_INTERVAL, ) -> list[Result]: - """Calculate all subset sum.""" + """Calculate all subset sum. + + Parameters: + targets: The list of targets. + numbers: The subset that we search for the sum + of its subset is equal to target. + interval_sec: The time interval (in seconds) for updating + the status. + """ class BruteForceExecutor(AbstractExecutor): @@ -57,30 +68,19 @@ class BruteForceExecutor(AbstractExecutor): Executor that use brute-force to solve subset sum problem. """ - def __init__( - self, - filename: str = None, - data_loader: AbstractDataLoader = ExcelDataLoader, - ): - """Initialize data loader and use it to load data. - - Parameters: - data_loader: The data loader use to load data. - filename: The file path. - """ - self.data_loader: AbstractDataLoader = data_loader() - self.data_loader.load(filename) - self._numbers = list(self.data_loader.numbers) - - def _calculate(self, target: Summons, interval_sec: int) -> Result: + def _calculate( + self, target: Summons, numbers: list[Summons], interval_sec: int + ) -> Result: """Find subset sum that is equal to target. Parameters: - target: The target we want to calculate. + targets: The target that we want to solve. + numbers: The subset that we search for the sum + of its subset is equal to target. interval_sec: The time interval (in seconds) for updating the status. """ - numbers = [i for i in self._numbers if i.amount <= target.amount] + numbers = [i for i in numbers if i.amount <= target.amount] total_calculation = 2 ** len(numbers) already_calculation = 0 start_time = time.time() @@ -104,20 +104,27 @@ def _calculate(self, target: Summons, interval_sec: int) -> Result: return Result(target, None) def calculate_all( - self, interval_sec: int = DEFAULT_INTERVAL + self, + targets: list[Summons], + numbers: list[Summons], + interval_sec: int = DEFAULT_INTERVAL, ) -> list[Result]: """Calculate all subset sum. Parameters: + targets: The list of targets. + numbers: The subset that we search for the sum + of its subset is equal to target. interval_sec: The time interval (in seconds) for updating the status. """ results = [] count = 0 + _numbers = list(numbers) overall_start_time = time.time() - for target in self.data_loader.targets: + for target in targets: start_time = time.time() - result = self._calculate(target, interval_sec) + result = self._calculate(target, numbers, interval_sec) end_time = time.time() _logger.info( f"Target: {target.amount}, " @@ -126,9 +133,8 @@ def calculate_all( results.append(result) if result.subset: for i in result.subset: - self._numbers.remove(i) + _numbers.remove(i) count = count + 1 - self._numbers = list(self.data_loader.numbers) elapsed_time = time.time() - overall_start_time _logger.info(f"Total elapsed time: {elapsed_time:.3f} " "seconds.") return results diff --git a/test/test_executor.py b/test/test_executor.py index ddfe160..7944afd 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -6,7 +6,7 @@ class FakeDataLoader(AbstractDataLoader): """A Fake Data Loader that simulate ExcelDataLoader.""" - def load(self, filename: str, reload=False): + def load(self): """Simulate the load method of ExcelDataLoader.""" numbers = [i for i in range(23)] targets = [sum(numbers) + 1, numbers[-1]] @@ -25,9 +25,10 @@ def load(self, filename: str, reload=False): def test_executor(): """Verify that BruteForceExecutor successfully solve problem.""" - eva = BruteForceExecutor(data_loader=FakeDataLoader) - assert eva.data_loader.loaded - results = eva.calculate_all() + data_loader = FakeDataLoader() + data_loader.load() + eva = BruteForceExecutor() + results = eva.calculate_all(data_loader.targets, data_loader.numbers) for i in results: if i.subset: assert sum([x.amount for x in i.subset]) == i.target.amount From 08d38b8347fb90ec355ff37b236773f7f0e794c1 Mon Sep 17 00:00:00 2001 From: komark06 Date: Tue, 19 Nov 2024 20:31:27 +0800 Subject: [PATCH 20/42] Fix mishandling of empty rows in data processing Added a check to handle cases where rows may be empty during iteration. This ensures only valid rows are processed, preventing potential errors. --- src/data_loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/data_loader.py b/src/data_loader.py index ff6d062..e9cc8d5 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -86,6 +86,8 @@ def load(self, filename: str, reload=False): sheet = workbook[workbook.sheetnames[1]] for row in sheet.iter_rows(values_only=True): tag = row[0] + if not tag: + break date = datetime.date.fromisoformat(tag.split("-")[0]) amount = abs(row[1]) obj = Summons(tag, date, amount) From 553d7ed2235a55d2dd8152c6c8beeb814272b696 Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 4 Dec 2024 11:23:58 +0800 Subject: [PATCH 21/42] Enhance tests for executor.py to cover all subset sum scenarios - Added test cases for subset sum problems with solutions. - Introduced additional tests for cases with no solutions. - Improved test readability and maintainability. --- test/test_executor.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/test/test_executor.py b/test/test_executor.py index 7944afd..b07a10f 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -1,15 +1,40 @@ import datetime from src.data_loader import AbstractDataLoader, Summons from src.executor import BruteForceExecutor +import pytest class FakeDataLoader(AbstractDataLoader): - """A Fake Data Loader that simulate ExcelDataLoader.""" + """A fake data loader that simulates ExcelDataLoader. + + This loader is designed to mimic the functionality of ExcelDataLoader + by generating a set of numbers and targets to simulate solving the + subset sum problem. + """ + + def __init__(self, solvable: bool = True): + """Initialize the FakeDataLoader. + + Args: + solvable: Determines if the subset sum problem generated by + this loader is solvable. + """ + super().__init__() + self.solvable = solvable def load(self): - """Simulate the load method of ExcelDataLoader.""" - numbers = [i for i in range(23)] - targets = [sum(numbers) + 1, numbers[-1]] + """Load simulated data. + + This method generates a set of numbers and targets. If the + `solvable` flag is True, the target will be a subset sum + achievable from the numbers. Otherwise, it will generate a + target that is not achievable. + """ + numbers = [i for i in range(10)] + if self.solvable: + targets = [numbers[-1]] + else: + targets = [sum(numbers) + 1] self.targets = [ Summons("targets", datetime.date(2020, 1, 1), target) @@ -23,12 +48,16 @@ def load(self): self._loaded = True -def test_executor(): +@pytest.mark.parametrize("solvable", [(True), (False)]) +def test_executor(solvable: FakeDataLoader): """Verify that BruteForceExecutor successfully solve problem.""" - data_loader = FakeDataLoader() + data_loader = FakeDataLoader(solvable) data_loader.load() eva = BruteForceExecutor() results = eva.calculate_all(data_loader.targets, data_loader.numbers) for i in results: if i.subset: assert sum([x.amount for x in i.subset]) == i.target.amount + assert solvable + else: + assert solvable is False From 7cef6b133fbd0b454394cf4426c48cdbb77a404d Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 4 Dec 2024 11:25:18 +0800 Subject: [PATCH 22/42] Remove redundant keyword pass --- src/data_loader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data_loader.py b/src/data_loader.py index e9cc8d5..e92e70b 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -55,7 +55,6 @@ def loaded(self): @abstractmethod def load(self): """Load data.""" - pass def sort(self): """Sort numbers and targets""" From 4c2fc6a119b53f23befa15201fdd231088dbe983 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 7 Dec 2024 22:41:57 +0800 Subject: [PATCH 23/42] Add function to export results to Excel format The output_excel function will transform results into Excel format. --- src/output.py | 64 ++++++++++++++++++++++++++++++++++ test/test_output.py | 85 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/output.py create mode 100644 test/test_output.py diff --git a/src/output.py b/src/output.py new file mode 100644 index 0000000..045dc6b --- /dev/null +++ b/src/output.py @@ -0,0 +1,64 @@ +import openpyxl +from openpyxl.utils import get_column_letter + +from src.data_loader import AbstractDataLoader +from src.executor import Result + + +def output_excel( + results: list[Result], + data_loader: AbstractDataLoader, + filename: str = "ex.xlsx", +): + account_length = 29.0 + amount_length = 10.0 + wb = openpyxl.Workbook() + sheet = wb.active + sheet["A1"] = sheet["D1"] = "憑證號碼" + sheet["B1"] = sheet["E1"] = "金額" + for i, target in enumerate(data_loader.targets): + sheet.cell(i + 2, 1, target.account) + sheet.cell(i + 2, 2, target.amount) + for i, number in enumerate(data_loader.numbers): + sheet.cell(i + 2, 4, number.account) + sheet.cell(i + 2, 5, number.amount) + sheet.column_dimensions["A"].width = account_length + sheet.column_dimensions["D"].width = account_length + sheet.column_dimensions["B"].width = amount_length + sheet.column_dimensions["E"].width = amount_length + # Output + output_account_length = 29.0 + output_amount_length = 10.0 + sheet_name = "配對表" + wb.create_sheet(sheet_name) + sheet = wb[sheet_name] + for idx, result in enumerate(results): + # Dynamically calculate the starting column for this result + start_column = idx * 5 + 1 + + # Define column widths and headers + column_widths = [ + output_account_length, + output_amount_length, + output_account_length, + output_amount_length, + ] + headers = ["憑證號碼", "目標值", "憑證號碼", "配對值"] + + # Set column widths and headers + for i, (width, header) in enumerate( + zip(column_widths, headers), start=start_column + ): + sheet.column_dimensions[get_column_letter(i)].width = width + sheet.cell(1, i, header) + + # Populate target account and amount + sheet.cell(2, start_column, result.target.account) + sheet.cell(2, start_column + 1, result.target.amount) + + # Populate subset data + for i, number in enumerate(result.subset, start=2): + sheet.cell(i, start_column + 2, number.account) + sheet.cell(i, start_column + 3, number.amount) + + wb.save(filename) diff --git a/test/test_output.py b/test/test_output.py new file mode 100644 index 0000000..f1e8b8f --- /dev/null +++ b/test/test_output.py @@ -0,0 +1,85 @@ +from datetime import date +from io import BytesIO +from itertools import zip_longest + +import openpyxl + +from src.data_loader import AbstractDataLoader, Summons +from src.executor import Result +from src.output import output_excel + + +class FakeDataLoader(AbstractDataLoader): + """A fake data loader that simulates ExcelDataLoader. + + This loader is designed to mimic the functionality of ExcelDataLoader + by generating a set of numbers and targets to simulate solving the + subset sum problem. + """ + + def load(self): + """Load simulated data. + + This method generates a set of numbers and targets. If the + `solvable` flag is True, the target will be a subset sum + achievable from the numbers. Otherwise, it will generate a + target that is not achievable. + """ + numbers = [i for i in range(10)] + targets = [numbers[0], numbers[1] + numbers[2]] + + self.targets = [ + Summons("targets", date(2020, 1, 1), target) for target in targets + ] + self.numbers = [ + Summons("numbers", date(2020, 1, 1), number) for number in numbers + ] + self.sort() + self._loaded = True + + +def test_output_excel(): + """Verify that output_excel successfully save output to file.""" + data_loader = FakeDataLoader() + data_loader.load() + results = [ + Result(data_loader.targets[0], [data_loader.numbers[0]]), + Result( + data_loader.targets[1], + [data_loader.numbers[1], data_loader.numbers[2]], + ), + ] + with BytesIO() as file: + output_excel(results, data_loader, file) + workbook = openpyxl.load_workbook(file) + + sheet = workbook[workbook.sheetnames[0]] + for row, target, number in zip_longest( + sheet.iter_rows(min_row=2, values_only=True), + data_loader.targets, + data_loader.numbers, + ): + if target: + assert target == Summons(row[0], target.date, row[1]) + if number: + assert number == Summons(row[3], number.date, row[4]) + sheet = workbook[workbook.sheetnames[1]] + for idx, result in enumerate(results): + # Dynamically calculate the starting column for this result + start_column = idx * 5 + 1 + assert sheet.cell(2, start_column).value == result.target.account + assert ( + sheet.cell(2, start_column + 1).value == result.target.amount + ) + for i, number in enumerate(result.subset, start=2): + assert ( + sheet.cell( + i, + start_column + 2, + ).value + == number.account + ) + assert ( + sheet.cell(i, start_column + 3, number.amount).value + == number.amount + ) From 30a39b613b0d962c7f7f6a8fc7970bc792f4cec0 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 8 Dec 2024 09:46:13 +0800 Subject: [PATCH 24/42] Add module docstring to output.py --- src/output.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/output.py b/src/output.py index 045dc6b..d4b2a6d 100644 --- a/src/output.py +++ b/src/output.py @@ -1,3 +1,5 @@ +"""This module is used to output to Excel format.""" + import openpyxl from openpyxl.utils import get_column_letter From 0f883b26dfa01205ce9b1dcff14cf90aa7e20762 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 8 Dec 2024 13:17:39 +0800 Subject: [PATCH 25/42] Adopt ruff format --- src/executor.py | 2 +- test/test_data_loader.py | 8 +++++--- test/test_executor.py | 10 ++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/executor.py b/src/executor.py index 5c92764..97ce299 100644 --- a/src/executor.py +++ b/src/executor.py @@ -7,7 +7,7 @@ from itertools import combinations from typing import Optional -from src.data_loader import AbstractDataLoader, ExcelDataLoader, Summons +from src.data_loader import AbstractDataLoader, Summons _logger = logging.getLogger(__name__) logging.basicConfig( diff --git a/test/test_data_loader.py b/test/test_data_loader.py index cc56611..1c2eb71 100644 --- a/test/test_data_loader.py +++ b/test/test_data_loader.py @@ -1,8 +1,10 @@ -from openpyxl import Workbook -from src.data_loader import ExcelDataLoader, Summons from datetime import date -from itertools import chain from io import BytesIO +from itertools import chain + +from openpyxl import Workbook + +from src.data_loader import ExcelDataLoader, Summons def test_load(): diff --git a/test/test_executor.py b/test/test_executor.py index b07a10f..82fbf97 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -1,7 +1,9 @@ import datetime + +import pytest + from src.data_loader import AbstractDataLoader, Summons from src.executor import BruteForceExecutor -import pytest class FakeDataLoader(AbstractDataLoader): @@ -31,11 +33,7 @@ def load(self): target that is not achievable. """ numbers = [i for i in range(10)] - if self.solvable: - targets = [numbers[-1]] - else: - targets = [sum(numbers) + 1] - + targets = [numbers[-1]] if self.solvable else [sum(numbers) + 1] self.targets = [ Summons("targets", datetime.date(2020, 1, 1), target) for target in targets From 90f11c0b12164cf27448a1d157d7d0cb7a072375 Mon Sep 17 00:00:00 2001 From: komark06 <87216234+komark06@users.noreply.github.com> Date: Sun, 8 Dec 2024 13:27:26 +0800 Subject: [PATCH 26/42] Create pre-commit.yml --- .github/workflows/pre-commit.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..52f5387 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,30 @@ +name: Pre-commit Check + +on: + pull_request: + branches: + - main + push: + branches: + - develop + +jobs: + pre-commit-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: | + pip install --upgrade pip + pip install pre-commit + + - name: Run pre-commit + run: pre-commit run --all-files From 66fef60d1b191a42b524e6181a6ca72b53ff48ea Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 8 Dec 2024 14:04:37 +0800 Subject: [PATCH 27/42] Add pytest.yml for unit test --- .github/workflows/pytest.yml | 38 +++++++++++++++++++++++++++++++++++ requirements.txt | Bin 0 -> 230 bytes 2 files changed, 38 insertions(+) create mode 100644 .github/workflows/pytest.yml create mode 100644 requirements.txt diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..3e47d37 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,38 @@ +name: Unit test + +on: + pull_request: + branches: + - main + push: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Run tests + run: pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..26b26ddff5af176180eafeb5f3c532b4c870cf6a GIT binary patch literal 230 zcmbV^OA5k35JcZv@D8(c6~72#(5twL8i*z&gT%|LuX6-xYKE@rdf!h$P2x^=ItGrc zoQ}7uS<=#T(;3_zdJPqGX1 Date: Sun, 8 Dec 2024 14:48:20 +0800 Subject: [PATCH 28/42] Add support for fromisoformat before Python 3.11 Because datetime.date.fromisoformat only supported the format YYYY-MM-DD before Python 3.11. So we format the string of date before pass into fromisoformat. --- src/data_loader.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/data_loader.py b/src/data_loader.py index e92e70b..c16f655 100644 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -87,7 +87,9 @@ def load(self, filename: str, reload=False): tag = row[0] if not tag: break - date = datetime.date.fromisoformat(tag.split("-")[0]) + date_str = tag.split("-")[0] + formatted_str = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" + date = datetime.date.fromisoformat(formatted_str) amount = abs(row[1]) obj = Summons(tag, date, amount) if amount in targets: From e1e276c7345a753a5c947af417347cb838d7f2b6 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 8 Dec 2024 20:07:04 +0800 Subject: [PATCH 29/42] Change the trigger of pre-commit.yml Now push of all branches will trigger pre-commit.yml. --- .github/workflows/pre-commit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 52f5387..c8cdcda 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -3,7 +3,7 @@ name: Pre-commit Check on: pull_request: branches: - - main + - "**" push: branches: - develop From 5341f0e98402320bf1e26fee3994ba3e4221cbaf Mon Sep 17 00:00:00 2001 From: komark06 Date: Mon, 9 Dec 2024 16:24:32 +0800 Subject: [PATCH 30/42] Correct and improve docstring --- src/executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/executor.py b/src/executor.py index 97ce299..5d58b72 100644 --- a/src/executor.py +++ b/src/executor.py @@ -74,8 +74,8 @@ def _calculate( """Find subset sum that is equal to target. Parameters: - targets: The target that we want to solve. - numbers: The subset that we search for the sum + target: The target that we want to solve. + numbers: The subset where we search for the sum of its subset is equal to target. interval_sec: The time interval (in seconds) for updating the status. From 4c04ade76f2e073353fc5a2ce1beb2134ab145d6 Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 11 Dec 2024 13:43:54 +0800 Subject: [PATCH 31/42] Remove redundant and outdated docstring The AbstractExecutor class already provides a docstring for the calculate_all method. This commit removes the redundant docstring to avoid duplication and maintain clarity. --- src/executor.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/executor.py b/src/executor.py index 5d58b72..64d5e9b 100644 --- a/src/executor.py +++ b/src/executor.py @@ -109,15 +109,6 @@ def calculate_all( numbers: list[Summons], interval_sec: int = DEFAULT_INTERVAL, ) -> list[Result]: - """Calculate all subset sum. - - Parameters: - targets: The list of targets. - numbers: The subset that we search for the sum - of its subset is equal to target. - interval_sec: The time interval (in seconds) for updating - the status. - """ results = [] count = 0 _numbers = list(numbers) From b15b9c0a6a4ae7859da80bb4ec182e3627552c0d Mon Sep 17 00:00:00 2001 From: komark06 Date: Wed, 11 Dec 2024 13:44:37 +0800 Subject: [PATCH 32/42] Remove outdated `data_loader` member The `data_loader` member was removed from `BruteForceExecutor` in a previous commit, but `AbstractExecutor` still contained the outdated reference. This commit removes the `data_loader` member from `AbstractExecutor` to keep the code and documentation consistent with the current implementation. --- src/executor.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/executor.py b/src/executor.py index 64d5e9b..c51220b 100644 --- a/src/executor.py +++ b/src/executor.py @@ -38,13 +38,8 @@ class Result: class AbstractExecutor(ABC): """ Abstract executor class that solve subset sum problem. - - Attributes: - data_loader: The data loader the load data. """ - data_loader: AbstractDataLoader - @abstractmethod def calculate_all( self, From e7b9904cca6d7b6a1bd41f05353f5416b00d2d5b Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 29 Dec 2024 14:54:34 +0800 Subject: [PATCH 33/42] Add uitls.py to refactor test code Move FakeDataLoader to utils.py and update related tests to use the new data loader. --- src/executor.py | 2 +- test/test_executor.py | 44 +--------------------------------------- test/test_output.py | 47 +++++++------------------------------------ test/utils.py | 43 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 84 deletions(-) create mode 100644 test/utils.py diff --git a/src/executor.py b/src/executor.py index c51220b..40a7ce4 100644 --- a/src/executor.py +++ b/src/executor.py @@ -7,7 +7,7 @@ from itertools import combinations from typing import Optional -from src.data_loader import AbstractDataLoader, Summons +from src.data_loader import Summons _logger = logging.getLogger(__name__) logging.basicConfig( diff --git a/test/test_executor.py b/test/test_executor.py index 82fbf97..84c49c8 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -1,49 +1,7 @@ -import datetime - import pytest -from src.data_loader import AbstractDataLoader, Summons from src.executor import BruteForceExecutor - - -class FakeDataLoader(AbstractDataLoader): - """A fake data loader that simulates ExcelDataLoader. - - This loader is designed to mimic the functionality of ExcelDataLoader - by generating a set of numbers and targets to simulate solving the - subset sum problem. - """ - - def __init__(self, solvable: bool = True): - """Initialize the FakeDataLoader. - - Args: - solvable: Determines if the subset sum problem generated by - this loader is solvable. - """ - super().__init__() - self.solvable = solvable - - def load(self): - """Load simulated data. - - This method generates a set of numbers and targets. If the - `solvable` flag is True, the target will be a subset sum - achievable from the numbers. Otherwise, it will generate a - target that is not achievable. - """ - numbers = [i for i in range(10)] - targets = [numbers[-1]] if self.solvable else [sum(numbers) + 1] - self.targets = [ - Summons("targets", datetime.date(2020, 1, 1), target) - for target in targets - ] - self.numbers = [ - Summons("numbers", datetime.date(2020, 1, 1), number) - for number in numbers - ] - self.sort() - self._loaded = True +from test.utils import FakeDataLoader @pytest.mark.parametrize("solvable", [(True), (False)]) diff --git a/test/test_output.py b/test/test_output.py index f1e8b8f..850c1e4 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,58 +1,25 @@ -from datetime import date from io import BytesIO from itertools import zip_longest import openpyxl -from src.data_loader import AbstractDataLoader, Summons -from src.executor import Result +from src.data_loader import Summons +from src.executor import BruteForceExecutor from src.output import output_excel - - -class FakeDataLoader(AbstractDataLoader): - """A fake data loader that simulates ExcelDataLoader. - - This loader is designed to mimic the functionality of ExcelDataLoader - by generating a set of numbers and targets to simulate solving the - subset sum problem. - """ - - def load(self): - """Load simulated data. - - This method generates a set of numbers and targets. If the - `solvable` flag is True, the target will be a subset sum - achievable from the numbers. Otherwise, it will generate a - target that is not achievable. - """ - numbers = [i for i in range(10)] - targets = [numbers[0], numbers[1] + numbers[2]] - - self.targets = [ - Summons("targets", date(2020, 1, 1), target) for target in targets - ] - self.numbers = [ - Summons("numbers", date(2020, 1, 1), number) for number in numbers - ] - self.sort() - self._loaded = True +from test.utils import FakeDataLoader def test_output_excel(): """Verify that output_excel successfully save output to file.""" data_loader = FakeDataLoader() data_loader.load() - results = [ - Result(data_loader.targets[0], [data_loader.numbers[0]]), - Result( - data_loader.targets[1], - [data_loader.numbers[1], data_loader.numbers[2]], - ), - ] + results = BruteForceExecutor().calculate_all( + data_loader.targets, data_loader.numbers + ) + results = 2 * results # Test multiple results. with BytesIO() as file: output_excel(results, data_loader, file) workbook = openpyxl.load_workbook(file) - sheet = workbook[workbook.sheetnames[0]] for row, target, number in zip_longest( sheet.iter_rows(min_row=2, values_only=True), diff --git a/test/utils.py b/test/utils.py new file mode 100644 index 0000000..691c88d --- /dev/null +++ b/test/utils.py @@ -0,0 +1,43 @@ +import datetime + +from src.data_loader import AbstractDataLoader, Summons + + +class FakeDataLoader(AbstractDataLoader): + """A fake data loader that simulates ExcelDataLoader. + + This loader is designed to mimic the functionality of ExcelDataLoader + by generating a set of numbers and targets to simulate solving the + subset sum problem. + """ + + def __init__(self, solvable: bool = True): + """Initialize the FakeDataLoader. + + Args: + solvable: Determines if the subset sum problem generated by + this loader is solvable. + """ + super().__init__() + self.solvable = solvable + + def load(self): + """Load simulated data. + + This method generates a set of numbers and targets. If the + `solvable` flag is True, the target will be a subset sum + achievable from the numbers. Otherwise, it will generate a + target that is not achievable. + """ + numbers = [i for i in range(10)] + targets = [numbers[-1]] if self.solvable else [sum(numbers) + 1] + self.targets = [ + Summons("targets", datetime.date(2020, 1, 1), target) + for target in targets + ] + self.numbers = [ + Summons("numbers", datetime.date(2020, 1, 1), number) + for number in numbers + ] + self.sort() + self._loaded = True From 9f56475e59e5a1b28ddaefe2517895201eb82383 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 8 Dec 2024 19:30:28 +0800 Subject: [PATCH 34/42] Add release-on-tag.yml This yml will release new program when there is a tag been pushed. --- .github/workflows/release-on-tag.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/release-on-tag.yml diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml new file mode 100644 index 0000000..d251415 --- /dev/null +++ b/.github/workflows/release-on-tag.yml @@ -0,0 +1,34 @@ +name: Release on New Tag + +on: + push: + tags: + - "*" + +jobs: + build-and-release: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install pyinstaller + pip install -r requirements.txt + + - name: Build executable with PyInstaller + run: | + pyinstaller --onefile --windowed src/main.py + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: dist\main.exe From 989ea3cc9dbf49d280f6c5e9a9ef2bf896aee5e9 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sun, 29 Dec 2024 16:37:47 +0800 Subject: [PATCH 35/42] Add callback mechanism to BruteForceExecutor By adding a callback mechanism to the BruteForceExecutor, the user can now receive progress updates and initialize the calculation status. This is useful for long-running calculations, as the user can now see the progress of the calculation. --- src/executor.py | 56 ++++++++++++++++++++++--------------------- test/test_executor.py | 21 +++++++++++----- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/executor.py b/src/executor.py index 40a7ce4..d5ad328 100644 --- a/src/executor.py +++ b/src/executor.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from itertools import combinations -from typing import Optional +from typing import Callable, Optional from src.data_loader import Summons @@ -40,12 +40,20 @@ class AbstractExecutor(ABC): Abstract executor class that solve subset sum problem. """ + def __init__(self): + self._init_status() + + def _init_status(self): + """Initialize the status of the executor.""" + self._total_calculation = 0 + self._already_calculation = 0 + @abstractmethod def calculate_all( self, targets: list[Summons], numbers: list[Summons], - interval_sec: int = DEFAULT_INTERVAL, + callback: Callable[[float], None] = lambda x: None, ) -> list[Result]: """Calculate all subset sum. @@ -53,8 +61,9 @@ def calculate_all( targets: The list of targets. numbers: The subset that we search for the sum of its subset is equal to target. - interval_sec: The time interval (in seconds) for updating - the status. + callback: A callback function that is called with the + progress of the calculation. Defaults to a no-op lambda + function. """ @@ -64,7 +73,10 @@ class BruteForceExecutor(AbstractExecutor): """ def _calculate( - self, target: Summons, numbers: list[Summons], interval_sec: int + self, + target: Summons, + numbers: list[Summons], + callback: Callable[[float], None] = lambda x: None, ) -> Result: """Find subset sum that is equal to target. @@ -72,45 +84,35 @@ def _calculate( target: The target that we want to solve. numbers: The subset where we search for the sum of its subset is equal to target. - interval_sec: The time interval (in seconds) for updating - the status. + callback: A callback function that is called with the + progress of the calculation. Defaults to a no-op lambda + function. """ numbers = [i for i in numbers if i.amount <= target.amount] - total_calculation = 2 ** len(numbers) - already_calculation = 0 - start_time = time.time() for r in range(1, len(numbers)): for combination in combinations(numbers, r): - if sum([i.amount for i in combination]) == target.amount: - _logger.debug( - f"Target {target.amount}: " - f"{tuple(i.amount for i in combination)}" - ) + total_amount = sum([i.amount for i in combination]) + self._already_calculation += 1 + callback(self._already_calculation / self._total_calculation) + if total_amount == target.amount: return Result(target, combination) - already_calculation += 1 - current_time = time.time() - if current_time - start_time > interval_sec: - _logger.info( - f"Calculating {target.amount}, " - f"{already_calculation/total_calculation*100:.2f}%" - ) - start_time = time.time() - _logger.debug(f"Target {target.amount}: NO result.") return Result(target, None) def calculate_all( self, targets: list[Summons], numbers: list[Summons], - interval_sec: int = DEFAULT_INTERVAL, + callback: Callable[[float], None] = lambda x: None, ) -> list[Result]: + self._init_status() + self._total_calculation = 2 ** len(numbers) results = [] count = 0 _numbers = list(numbers) overall_start_time = time.time() for target in targets: start_time = time.time() - result = self._calculate(target, numbers, interval_sec) + result = self._calculate(target, numbers, callback) end_time = time.time() _logger.info( f"Target: {target.amount}, " @@ -122,5 +124,5 @@ def calculate_all( _numbers.remove(i) count = count + 1 elapsed_time = time.time() - overall_start_time - _logger.info(f"Total elapsed time: {elapsed_time:.3f} " "seconds.") + _logger.info(f"Total elapsed time: {elapsed_time:.3f} seconds.") return results diff --git a/test/test_executor.py b/test/test_executor.py index 84c49c8..5e7573a 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from src.executor import BruteForceExecutor @@ -6,14 +8,21 @@ @pytest.mark.parametrize("solvable", [(True), (False)]) def test_executor(solvable: FakeDataLoader): - """Verify that BruteForceExecutor successfully solve problem.""" + """Verify that BruteForceExecutor successfully solve problem. + + Make sure that callback is called at least once. + """ + mock_callback = MagicMock() + data_loader = FakeDataLoader(solvable) data_loader.load() eva = BruteForceExecutor() - results = eva.calculate_all(data_loader.targets, data_loader.numbers) + results = eva.calculate_all( + data_loader.targets, data_loader.numbers, mock_callback + ) for i in results: - if i.subset: + if solvable: assert sum([x.amount for x in i.subset]) == i.target.amount - assert solvable - else: - assert solvable is False + mock_callback.assert_called() + args, _ = mock_callback.call_args + assert isinstance(args[0], float), f"Expected float, got {type(args[0])}" From 2fed41dc1e37eaef041bec4d46b515a964557651 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 11 Jan 2025 09:30:14 +0800 Subject: [PATCH 36/42] Initialize data loading in FakeDataLoader So we don't need to call `load_data` in every test. --- test/test_executor.py | 1 - test/test_output.py | 1 - test/utils.py | 1 + 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_executor.py b/test/test_executor.py index 5e7573a..3b219dc 100644 --- a/test/test_executor.py +++ b/test/test_executor.py @@ -15,7 +15,6 @@ def test_executor(solvable: FakeDataLoader): mock_callback = MagicMock() data_loader = FakeDataLoader(solvable) - data_loader.load() eva = BruteForceExecutor() results = eva.calculate_all( data_loader.targets, data_loader.numbers, mock_callback diff --git a/test/test_output.py b/test/test_output.py index 850c1e4..7805b6d 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -12,7 +12,6 @@ def test_output_excel(): """Verify that output_excel successfully save output to file.""" data_loader = FakeDataLoader() - data_loader.load() results = BruteForceExecutor().calculate_all( data_loader.targets, data_loader.numbers ) diff --git a/test/utils.py b/test/utils.py index 691c88d..875ea5f 100644 --- a/test/utils.py +++ b/test/utils.py @@ -20,6 +20,7 @@ def __init__(self, solvable: bool = True): """ super().__init__() self.solvable = solvable + self.load() def load(self): """Load simulated data. From 5966178bde7aeeb95f9beb57f702117330823851 Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 11 Jan 2025 09:31:14 +0800 Subject: [PATCH 37/42] Add subprocess module for multiprocessing support - Introduce `src/subprocess.py` to handle parallel calculations. - Add `test/test_subprocess.py` to test the subprocess module. - Update `test/utils.py` to integrate with the new subprocess module. This enhancement prepares for future GUI responsiveness improvements. --- src/subprocess.py | 105 ++++++++++++++++++++++++++++++++++++++++ test/test_subprocess.py | 102 ++++++++++++++++++++++++++++++++++++++ test/utils.py | 49 +++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 src/subprocess.py create mode 100644 test/test_subprocess.py diff --git a/src/subprocess.py b/src/subprocess.py new file mode 100644 index 0000000..4e63e11 --- /dev/null +++ b/src/subprocess.py @@ -0,0 +1,105 @@ +"""This module provides a class to manage subprocesses.""" + +import multiprocessing +import queue +import time +from abc import abstractmethod +from typing import Callable + +from src.data_loader import Summons +from src.executor import AbstractExecutor, BruteForceExecutor, Result + + +def _calculate( + executor: BruteForceExecutor, + queue: multiprocessing.Queue, + targets: list[Summons], + numbers: list[Summons], + interval: float = 1.0, +): + """Calculate all subset sum. Use as a child process.""" + start_time = time.time() + + def callback(progress: float): + nonlocal start_time + if time.time() - start_time > interval: + queue.put(progress) + start_time = time.time() + print(progress * 100) + + return executor.calculate_all(targets, numbers, callback=callback) + + +class AbstractSubprocessManager: + @abstractmethod + def is_running(self): + """Check if the subprocess is running.""" + + @abstractmethod + def terminate(self): + """Terminate the subprocess.""" + + @abstractmethod + def start_calculation( + self, + executor: AbstractExecutor, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[list[Result]], None], + error_callback: Callable[[Exception], None], + interval: float, + ): + """Start the calculation in a subprocess.""" + + @abstractmethod + def stop_calculation(self): + """Stop the calculation and renew resources.""" + + @abstractmethod + def update_status(self): + """Update the status of the calculation.""" + + +class SubprocessManager: + def __init__(self): + self.async_result = None + self.queue = multiprocessing.Manager().Queue() + self.pool = multiprocessing.Pool() + + def terminate(self): + """Terminate the subprocess.""" + self.pool.terminate() + + def is_running(self): + """Check if the subprocess is running.""" + return self.async_result is not None and not self.async_result.ready() + + def start_calculation( + self, + executor: AbstractExecutor, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[list[Result]], None] = lambda x: None, + error_callback: Callable[[Exception], None] = lambda x: None, + interval: float = 1.0, + ): + """Start the calculation in a subprocess.""" + self.async_result = self.pool.apply_async( + _calculate, + (executor, self.queue, targets, numbers, interval), + callback=callback, + error_callback=error_callback, + ) + + def stop_calculation(self): + """Stop the calculation and renew resources.""" + self.pool.terminate() + self.async_result = None + self.pool = multiprocessing.Pool() + self.queue = multiprocessing.Manager().Queue() + + def update_status(self): + try: + return self.queue.get_nowait() + except queue.Empty: + return None diff --git a/test/test_subprocess.py b/test/test_subprocess.py new file mode 100644 index 0000000..fa9bc7d --- /dev/null +++ b/test/test_subprocess.py @@ -0,0 +1,102 @@ +import multiprocessing +import queue +from unittest.mock import Mock + +import pytest + +from src.executor import BruteForceExecutor +from src.subprocess import SubprocessManager, _calculate +from test.utils import ExceptionExecutor, FakeDataLoader, InfiniteExecutor + + +@pytest.fixture +def manager_instance(): + manager = SubprocessManager() + yield manager + manager.terminate() + + +def test_calculate(): + """Test the _calculate function.""" + queue = multiprocessing.Queue() + executor = BruteForceExecutor() + data_loader = FakeDataLoader() + _calculate(executor, queue, data_loader.targets, data_loader.numbers, -1.0) + assert not queue.empty() + + +def test_start_calculation(manager_instance: SubprocessManager): + """Test the start_calculation method of the SubprocessManager.""" + results = None + + def get_results(outcome): + nonlocal results + results = outcome + + data_loader = FakeDataLoader() + manager_instance.start_calculation( + BruteForceExecutor(), + data_loader.targets, + data_loader.numbers, + get_results, + ) + while manager_instance.is_running(): + pass + assert results + + +def test_start_calculation_error(manager_instance: SubprocessManager): + """Test error callback of start_calculation in SubprocessManager.""" + error_callback = Mock() + data_loader = FakeDataLoader() + manager_instance.start_calculation( + ExceptionExecutor(), + data_loader.targets, + data_loader.numbers, + error_callback=error_callback, + ) + while manager_instance.is_running(): + pass + error_callback.assert_called_once() + + +def test_is_running(manager_instance: SubprocessManager): + """Test the is_running method of the SubprocessManager.""" + data_loader = FakeDataLoader() + assert not manager_instance.is_running() + manager_instance.start_calculation( + InfiniteExecutor(), + data_loader.targets, + data_loader.numbers, + ) + assert manager_instance.is_running() + + +def test_stop_calculation(manager_instance: SubprocessManager): + """Test the stop_calculation method of the SubprocessManager.""" + data_loader = FakeDataLoader() + manager_instance.start_calculation( + InfiniteExecutor(), + data_loader.targets, + data_loader.numbers, + ) + manager_instance.stop_calculation() + assert not manager_instance.is_running() + + +def test_update_status(manager_instance: SubprocessManager): + """Test the update_status method of the SubprocessManager.""" + manager_instance.queue = Mock() + expected_progress = 0.5 + manager_instance.queue.get_nowait.return_value = expected_progress + progress = manager_instance.update_status() + assert progress == expected_progress + + +def test_update_status_empty(manager_instance: SubprocessManager): + """Test update_status method of SubprocessManager when queue is empty.""" + manager_instance.queue = Mock() + exception = queue.Empty + manager_instance.queue.get_nowait.side_effect = exception + progress = manager_instance.update_status() + assert progress is None diff --git a/test/utils.py b/test/utils.py index 875ea5f..349387d 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,6 +1,9 @@ import datetime +import time +from typing import Callable from src.data_loader import AbstractDataLoader, Summons +from src.executor import AbstractExecutor, Result class FakeDataLoader(AbstractDataLoader): @@ -42,3 +45,49 @@ def load(self): ] self.sort() self._loaded = True + + +class InfiniteExecutor(AbstractExecutor): + """An executor that never finishes the calculation. + + This executor is designed to simulate an executor that never + finishes the calculation. It is used to test the timeout feature + of the SubprocessManager. + """ + + def calculate_all( + self, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[float], None] = lambda x: None, + ) -> list[Result]: + """Calculate all subset sum. + + This method will never finish the calculation. It is used to + test the timeout feature of the SubprocessManager. + """ + while True: + callback(0.5) + time.sleep(1) + + +class ExceptionExecutor(AbstractExecutor): + """An executor that raises an exception. + + This executor is designed to simulate an executor that raises an + exception during the calculation. It is used to test the error + handling feature of the SubprocessManager. + """ + + def calculate_all( + self, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[float], None] = lambda x: None, + ) -> list[Result]: + """Calculate all subset sum. + + This method will raise an exception. It is used to test the + error handling feature of the SubprocessManager. + """ + raise ValueError("Simulated Executor Error") From fd13f0dd0585dc108c3996855e1b1523e9bedb11 Mon Sep 17 00:00:00 2001 From: komark06 Date: Thu, 23 Jan 2025 08:55:35 +0800 Subject: [PATCH 38/42] Add pydocstyle to ruff.toml We use pydocstyle mainly for the docstring to follow PEP 8. --- ruff.toml | 6 ++---- test/test_subprocess.py | 5 ++++- test/utils.py | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ruff.toml b/ruff.toml index 05a94a9..7f04118 100644 --- a/ruff.toml +++ b/ruff.toml @@ -17,7 +17,5 @@ select = [ "I", ] -[format] -docstring-code-format = true -docstring-code-line-length = 72 - +[pycodestyle] +max-doc-length = 72 diff --git a/test/test_subprocess.py b/test/test_subprocess.py index fa9bc7d..eeacbf4 100644 --- a/test/test_subprocess.py +++ b/test/test_subprocess.py @@ -94,7 +94,10 @@ def test_update_status(manager_instance: SubprocessManager): def test_update_status_empty(manager_instance: SubprocessManager): - """Test update_status method of SubprocessManager when queue is empty.""" + """Test update_status method of SubprocessManager. + + Test when the queue is empty. + """ manager_instance.queue = Mock() exception = queue.Empty manager_instance.queue.get_nowait.side_effect = exception diff --git a/test/utils.py b/test/utils.py index 349387d..9e54700 100644 --- a/test/utils.py +++ b/test/utils.py @@ -9,17 +9,19 @@ class FakeDataLoader(AbstractDataLoader): """A fake data loader that simulates ExcelDataLoader. - This loader is designed to mimic the functionality of ExcelDataLoader - by generating a set of numbers and targets to simulate solving the - subset sum problem. + This loader is designed to mimic the functionality of + ExcelDataLoader by generating a set of numbers and targets to + simulate solving the subset sum problem. """ def __init__(self, solvable: bool = True): """Initialize the FakeDataLoader. Args: + ---- solvable: Determines if the subset sum problem generated by this loader is solvable. + """ super().__init__() self.solvable = solvable From 58799e7cd7e4265a7b26383063bfa2721c843c1a Mon Sep 17 00:00:00 2001 From: komark06 Date: Thu, 23 Jan 2025 08:58:37 +0800 Subject: [PATCH 39/42] Make FakeDataLoader.load generic for flexibility The load method in FakeDataLoader has been updated to be generic, allowing it to work seamlessly with different data loaders. This enhancement improves testing flexibility by enabling the use of various data loading scenarios. --- test/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils.py b/test/utils.py index 9e54700..c535658 100644 --- a/test/utils.py +++ b/test/utils.py @@ -27,7 +27,7 @@ def __init__(self, solvable: bool = True): self.solvable = solvable self.load() - def load(self): + def load(self, *args, **kwargs): """Load simulated data. This method generates a set of numbers and targets. If the From c5fdb8d839d5c2b51da14c51296afe06ab26a11b Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 1 Feb 2025 10:06:11 +0800 Subject: [PATCH 40/42] Enable all features of pycodestyle in ruff.toml --- ruff.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/ruff.toml b/ruff.toml index 7f04118..1f808b1 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,6 +5,7 @@ line-length = 79 select = [ # pycodestyle "E", + "W", # Pyflakes "F", # pyupgrade From 7552e94415ca4bac9b4c52e3aaf8f5665fd768fd Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 1 Feb 2025 10:21:32 +0800 Subject: [PATCH 41/42] Add GUI interface We add GUI interface so that user can use the program easily. --- src/gui.py | 178 +++++++++++++++++++++++++++++++ test/test_gui.py | 265 +++++++++++++++++++++++++++++++++++++++++++++++ test/utils.py | 73 +++++++++++++ 3 files changed, 516 insertions(+) create mode 100644 src/gui.py create mode 100644 test/test_gui.py diff --git a/src/gui.py b/src/gui.py new file mode 100644 index 0000000..9d4039d --- /dev/null +++ b/src/gui.py @@ -0,0 +1,178 @@ +"""This module is used to create GUI and use it.""" + +import multiprocessing +import multiprocessing.pool +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from src.data_loader import AbstractDataLoader, ExcelDataLoader, Summons +from src.executor import BruteForceExecutor, Result +from src.output import output_excel +from src.subprocess import AbstractSubprocessManager, SubprocessManager + + +class GUI: + + def __init__( + self, + data_loader: AbstractDataLoader, + executor: BruteForceExecutor, + manager: AbstractSubprocessManager, + interval: float = 0.0, + ): + self.root = None + self.data_loader = data_loader + self.executor = executor + self.interval = interval + try: + self._init_tk() + self.manager = manager + except Exception as e: + messagebox.showerror("錯誤", f"初始化失敗: {str(e)}") + self.cleanup() + raise e + + def _init_tk(self): + """Initialize the tkinter GUI.""" + font = None + root = tk.Tk() + self.label_var = tk.StringVar() + self.root = root + root.protocol("WM_DELETE_WINDOW", self.cleanup) + root.title("會計銷帳") + root.minsize(400, 200) + self.status_label = ttk.Label(self.root, textvariable=self.label_var) + self.status_label.config(font=(font, 14)) + style = ttk.Style() + style.configure("Custom.TButton", font=(font, 12)) + self.button = ttk.Button( + self.root, + style="Custom.TButton", + ) + self.set_initial_state() + self.status_label.pack(pady=20) + self.button.pack(pady=20) + + def cleanup(self): + """Cleanup resources. + + Ignore any exceptions that may occur because the GUI is being closed. + """ + try: + self.manager.terminate() + except Exception: + pass + try: + self.root.destroy() + except Exception: + pass + + def set_initial_state(self): + """Set the initial screen.""" + self.label_var.set("請選擇要讀取的檔案") + self.button.configure(text="選擇檔案", command=self.run_action) + + def set_running_state(self): + """Set the screen and command when calculation is running.""" + self.label_var.set("開始執行") + self.button.configure(text="中止", command=self.stop_action) + + def handle_error(self, error_message): + """Show error message and set screen to initial state.""" + messagebox.showerror("錯誤", error_message) + self.set_initial_state() + + def subprocess_done(self, results: list[Result]): + """Save the results.""" + self.save_file(results) + + def subprocess_error(self, e: BaseException): + """Handle error from subprocess.""" + self.handle_error(f"計算時發生錯誤:{str(e)}") + + def update_status(self): + """Update the status of the calculation.""" + if progress := self.manager.update_status(): + print(f"進度: {progress*100:.2%}") + self.label_var.set(f"進度: {progress*100:.2%}") + if self.manager.is_running(): + self.root.after(3000, self.update_status) + + def open_file(self): + """Load data from file.""" + file_path = filedialog.askopenfilename( + title="讀取檔案", filetypes=[("*.xlsx *.xls", ".xlsx .xls")] + ) + if not file_path: + return + self.data_loader.load(file_path, reload=True) + filename = Path(file_path).name + self.label_var.set(f"讀取檔案:{filename}") + return file_path + + def save_file(self, results: list[Result]): + try: + filename = filedialog.asksaveasfilename( + title="儲存檔案", + defaultextension=".xlsx", + filetypes=[("*.xlsx", ".xlsx")], + ) + if not filename: + filename = Path("export.xlsx").absolute() # Default path + output_excel(results, self.data_loader, filename) + self.label_var.set(f"結果已經寫入 {filename}\n請選擇新檔案") + self.button.configure(text="選擇檔案", command=self.run_action) + except Exception as e: + self.handle_error(f"檔案寫入時發生錯誤:{str(e)}") + + def run_action(self): + """Load data and start calculation.""" + try: + if not self.open_file(): + return + except Exception as e: + self.handle_error(f"檔案讀取時發生錯誤:{str(e)}") + return + targets = self.data_loader.targets + numbers = self.data_loader.numbers + try: + self.manager.start_calculation( + self.executor, + targets, + numbers, + self.subprocess_done, + self.subprocess_error, + self.interval, + ) + self.set_running_state() + self.update_status() + except Exception as e: + self.handle_error(f"啟動計算時發生錯誤:{str(e)}") + + def stop_action(self): + """Stop the calculation and set screen to initial state.""" + try: + self.manager.stop_calculation() + except Exception as e: + self.handle_error(f"無法建立 processes pool 或 queue:{str(e)}") + raise e + try: + self.set_initial_state() + except Exception as e: + self.handle_error(f"無法更新GUI:{str(e)}") + raise e + + def mainloop(self): + self.root.mainloop() + + +def run_gui(): + """Run the GUI. + + NOTE: This function must run straight after the + if __name__ == '__main__' line of the main module. + """ + multiprocessing.freeze_support() + app = GUI(ExcelDataLoader(), BruteForceExecutor(), SubprocessManager()) + app.mainloop() diff --git a/test/test_gui.py b/test/test_gui.py new file mode 100644 index 0000000..0f6a639 --- /dev/null +++ b/test/test_gui.py @@ -0,0 +1,265 @@ +from src.gui import GUI +from src.executor import BruteForceExecutor, Result +from unittest.mock import patch +import pytest +from test.utils import ( + InfiniteExecutor, + FakeDataLoader, + FakeSubprocessManager, + ImmediateSubprocessManager, +) +from src.data_loader import Summons +from src.subprocess import SubprocessManager +import datetime +from pathlib import Path + + +def _create_gui_instance(data_loader, executor, manager, interval=1.0): + """Factory function to create a GUI instance and ensure cleanup.""" + gui = GUI(data_loader, executor, manager, interval) + yield gui + gui.cleanup() + + +@pytest.fixture +def gui_instance_infinite(): + """Create a GUI instance with infinite executor.""" + yield from _create_gui_instance( + FakeDataLoader(), InfiniteExecutor(), SubprocessManager(), 0.0 + ) + + +@pytest.fixture +def gui_instance_fake_manager(): + """Create a GUI instance with a fake subprocess manager. + + This fixture is useful when the subprocess manager is not needed, + as it avoids initializing any real subprocess manager, which can + speed up tests. + """ + yield from _create_gui_instance( + FakeDataLoader(), BruteForceExecutor(), FakeSubprocessManager() + ) + + +@pytest.fixture +def gui_instance_immediate(): + """Create a GUI instance with an subprocess manager that returns results immediately.""" + yield from _create_gui_instance( + FakeDataLoader(), BruteForceExecutor(), ImmediateSubprocessManager() + ) + + +def test_gui_initialization(gui_instance_fake_manager: GUI): + """Test GUI initialization.""" + assert gui_instance_fake_manager.root + assert gui_instance_fake_manager.data_loader + assert gui_instance_fake_manager.executor + assert gui_instance_fake_manager.manager + + +def test_gui_initialization_failure(): + """Test GUI initialization failure. + + Ensure that the GUI shows an error message when it fails to + initialize. Also, ensure that the cleanup method is called. + """ + with patch( + "src.gui.GUI._init_tk", side_effect=ValueError("Simulated GUI Error") + ), patch("tkinter.messagebox.showerror") as mock_showerror, patch( + "src.gui.GUI.cleanup" + ) as mock_cleanup: + with pytest.raises(ValueError, match="Simulated GUI Error"): + GUI( + FakeDataLoader(), BruteForceExecutor(), FakeSubprocessManager() + ) + mock_cleanup.assert_called_once() + mock_showerror.assert_called_once_with( + "錯誤", f"初始化失敗: {"Simulated GUI Error"}" + ) + + +def test_set_initial_state(gui_instance_fake_manager: GUI): + """Test the GUI state when it is initialized.""" + assert gui_instance_fake_manager.label_var.get() == "請選擇要讀取的檔案" + assert gui_instance_fake_manager.button.cget("text") == "選擇檔案" + + +def test_set_running_state(gui_instance_infinite: GUI): + """Test the GUI state when the calculation is running.""" + with patch("src.gui.GUI.open_file", return_value="test.xlsx"): + gui_instance_infinite.run_action() + assert gui_instance_infinite.label_var.get() == "開始執行" + assert gui_instance_infinite.button.cget("text") == "中止" + gui_instance_infinite.cleanup() + + +def test_handle_error(gui_instance_fake_manager: GUI): + """Test the handle_error method of the GUI.""" + error_message = "Simulated Error" + with patch("tkinter.messagebox.showerror") as mock_showerror, patch.object( + gui_instance_fake_manager, + "set_initial_state", + wraps=gui_instance_fake_manager.set_initial_state, + ) as mocked_set_initial_state: + gui_instance_fake_manager.handle_error(error_message) + mock_showerror.assert_called_once_with("錯誤", error_message) + mocked_set_initial_state.assert_called_once() + + +@pytest.mark.parametrize( + "filename", + [ + "test.xlsx", + None, + ], +) +def test_open_file(filename: str | None, gui_instance_fake_manager: GUI): + """Test the open_file method of the GUI.""" + with patch("tkinter.filedialog.askopenfilename", return_value=filename): + file_path = gui_instance_fake_manager.open_file() + assert file_path == filename + if filename: + assert ( + gui_instance_fake_manager.label_var.get() + == f"讀取檔案:{filename}" + ) + else: + assert ( + gui_instance_fake_manager.label_var.get() + == "請選擇要讀取的檔案" + ) + + +@pytest.mark.parametrize( + "filename", + [ + "test.xlsx", + None, + ], +) +def test_save_file(filename: str | None, gui_instance_fake_manager: GUI): + """Test the save_file method of the GUI.""" + results = [ + Result( + Summons("test", datetime.date(2020, 1, 1), 1), + [Summons("test", datetime.date(2020, 1, 1), 1)], + ) + ] + with patch( + "tkinter.filedialog.asksaveasfilename", return_value=filename + ), patch("src.gui.output_excel") as mock_output_excel: + gui_instance_fake_manager.save_file(results) + if not filename: + filename = Path("export.xlsx").absolute() + mock_output_excel.assert_called_once_with( + results, gui_instance_fake_manager.data_loader, filename + ) + assert ( + f"結果已經寫入 {filename}" + in gui_instance_fake_manager.label_var.get() + ) + assert gui_instance_fake_manager.button.cget("text") == "選擇檔案" + + +def test_save_file_failure(gui_instance_fake_manager: GUI): + """Test the save_file method of the GUI when it fails.""" + error_message = "Simulated Save File Error" + with patch( + "tkinter.filedialog.asksaveasfilename", + side_effect=ValueError(error_message), + ), patch("src.gui.GUI.handle_error") as mock_handle_error: + gui_instance_fake_manager.save_file(None) + mock_handle_error.assert_called_once_with( + f"檔案寫入時發生錯誤:{error_message}" + ) + + +def test_run_action(gui_instance_immediate: GUI): + """Test the run_action method of the GUI.""" + + with patch( + "src.gui.GUI.open_file", return_value="test.xlsx" + ), patch.object( + gui_instance_immediate, "subprocess_done" + ) as mock_subprocess_done: + + gui_instance_immediate.run_action() + mock_subprocess_done.assert_called_once() + assert ( + gui_instance_immediate.manager.results + == mock_subprocess_done.call_args[0][0] + ) + + +@pytest.mark.parametrize( + "target,base_error_message", + [ + ( + "src.gui.GUI.open_file", + "檔案讀取時發生錯誤:", + ), + ( + "src.gui.GUI.set_running_state", + "啟動計算時發生錯誤:", + ), + ], +) +def test_run_action_failure( + target: str, base_error_message: str, gui_instance_fake_manager: GUI +): + """Test the run_action method of the GUI when it fails.""" + + error_message = "Simulated Error" + with patch( + target, + side_effect=ValueError(error_message), + ), patch( + "tkinter.filedialog.askopenfilename", return_value="test.xlsx" + ), patch("src.gui.GUI.handle_error") as mock_handle_error: + gui_instance_fake_manager.run_action() + mock_handle_error.assert_called_once_with( + f"{base_error_message}{error_message}" + ) + + +def test_stop_action(gui_instance_infinite: GUI): + """Test the stop action method of the GUI.""" + with patch( + "src.gui.GUI.open_file", return_value="test.xlsx" + ), patch.object( + gui_instance_infinite, + "set_initial_state", + wraps=gui_instance_infinite.set_initial_state, + ) as mocked_set_initial_state: + gui_instance_infinite.run_action() + assert gui_instance_infinite.manager.is_running() + gui_instance_infinite.stop_action() + assert not gui_instance_infinite.manager.is_running() + mocked_set_initial_state.assert_called_once() + + +@pytest.mark.parametrize( + "target,base_error_message", + [ + ( + "test.utils.FakeSubprocessManager.stop_calculation", + "無法建立 processes pool 或 queue:", + ), + ("src.gui.GUI.set_initial_state", "無法更新GUI:"), + ], +) +def test_stop_action_failure( + target: str, base_error_message: str, gui_instance_fake_manager: GUI +): + """Test the stop action method of the GUI when it fails.""" + error_message = "Simulated Error" + with patch( + target, + side_effect=ValueError(error_message), + ), patch("src.gui.GUI.handle_error") as mock_handle_error: + with pytest.raises(ValueError, match=error_message): + gui_instance_fake_manager.stop_action() + mock_handle_error.assert_called_once_with( + f"{base_error_message}{error_message}" + ) diff --git a/test/utils.py b/test/utils.py index c535658..b6e8316 100644 --- a/test/utils.py +++ b/test/utils.py @@ -4,6 +4,7 @@ from src.data_loader import AbstractDataLoader, Summons from src.executor import AbstractExecutor, Result +from src.subprocess import AbstractSubprocessManager class FakeDataLoader(AbstractDataLoader): @@ -93,3 +94,75 @@ def calculate_all( error handling feature of the SubprocessManager. """ raise ValueError("Simulated Executor Error") + + +class FakeSubprocessManager(AbstractSubprocessManager): + """A fake subprocess manager that simulates SubprocessManager. + + This manager is fake subprocess manager that do nothing. + """ + + def is_running(self) -> bool: + return False + + def terminate(self): + pass + + def start_calculation( + self, + executor: AbstractExecutor, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[list[Result]], None], + error_callback: Callable[[Exception], None], + interval: float, + ): + pass + + def stop_calculation(self): + pass + + def update_status(self): + pass + + +class ImmediateSubprocessManager(AbstractSubprocessManager): + """A fake subprocess manager that simulates SubprocessManager. + + This manager is a fake subprocess manager that simulates the + calculation. It will return a set of results immediately after the + calculation starts. The results are the same as the results from the + instance of this class. + """ + + def __init__(self): + super().__init__() + self.results = [ + Result( + Summons("test", datetime.date(2020, 1, 1), 1), + [Summons("test", datetime.date(2020, 1, 1), 1)], + ) + ] + + def is_running(self) -> bool: + return False + + def terminate(self): + pass + + def start_calculation( + self, + executor: AbstractExecutor, + targets: list[Summons], + numbers: list[Summons], + callback: Callable[[list[Result]], None], + error_callback: Callable[[Exception], None], + interval: float, + ): + callback(self.results) + + def stop_calculation(self): + pass + + def update_status(self): + pass From 8f0cce09275931f17f007a44c041feda5f644e8f Mon Sep 17 00:00:00 2001 From: komark06 Date: Sat, 1 Feb 2025 10:30:18 +0800 Subject: [PATCH 42/42] Update GitHub action to only run on main branch --- .github/workflows/release-on-tag.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index d251415..09cf042 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -2,6 +2,8 @@ name: Release on New Tag on: push: + branches: + - main tags: - "*"