Skip to content

Commit d9f50ae

Browse files
committed
login, logout, optional locale, progress format
1 parent c1f3aa1 commit d9f50ae

File tree

7 files changed

+159
-74
lines changed

7 files changed

+159
-74
lines changed

tmc-langs-cli/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ walkdir = "2"
1414
serde_json = "1"
1515
env_logger = "0.7"
1616
log = "0.4"
17-
rpassword = { git = "https://github.com/conradkleinespel/rpassword", rev = "189c388" } # fixes a windows test issue, use until version 5 is released
17+
rpassword = "4"
1818
url = "2"
1919
serde = "1"
2020
anyhow = "1"
2121
ansi_term = "0.12"
2222
quit = "1"
23+
dirs = "3"
2324

2425
[dev-dependencies]
2526
tempfile = "3"

tmc-langs-cli/src/main.rs

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ use clap::{App, Arg, Error, ErrorKind, SubCommand};
66
use serde::Serialize;
77
use std::collections::HashMap;
88
use std::env;
9-
use std::fs::File;
9+
use std::fs::{self, File};
1010
use std::io::Write;
1111
use std::path::{Path, PathBuf};
12-
use tmc_langs_core::{FeedbackAnswer, TmcCore};
12+
use tmc_langs_core::{FeedbackAnswer, TmcCore, Token};
1313
use tmc_langs_framework::io::submission_processing;
1414
use tmc_langs_util::{task_executor, Language};
1515
use url::Url;
@@ -159,12 +159,23 @@ fn run() -> Result<()> {
159159

160160
.subcommand(SubCommand::with_name("core")
161161
.about("tmc-core commands. The program will ask for your TMC password through stdin.")
162-
.arg(Arg::with_name("email")
163-
.help("The email associated with your TMC account.")
164-
.long("email")
162+
.arg(Arg::with_name("clientName")
163+
.help("Name used to differentiate between different TMC clients")
164+
.long("clientName")
165165
.required(true)
166166
.takes_value(true))
167167

168+
.subcommand(SubCommand::with_name("login")
169+
.about("Login and store OAuth2 token in config.")
170+
.arg(Arg::with_name("email")
171+
.help("The email address of your TMC account")
172+
.long("email")
173+
.required(true)
174+
.takes_value(true)))
175+
176+
.subcommand(SubCommand::with_name("logout")
177+
.about("Login and remove OAuth2 token from config."))
178+
168179
.subcommand(SubCommand::with_name("get-organizations")
169180
.about("Get organizations."))
170181

@@ -261,7 +272,6 @@ fn run() -> Result<()> {
261272
.arg(Arg::with_name("locale")
262273
.help("Language as a three letter ISO 639-3 code, e.g. 'eng' or 'fin'.")
263274
.long("locale")
264-
.required(true)
265275
.takes_value(true)))
266276

267277
.subcommand(SubCommand::with_name("get-exercise-updates")
@@ -518,14 +528,50 @@ fn run() -> Result<()> {
518528
// set progress report to print the updates to stdout as JSON
519529
core.set_progress_report(|update| println!("{}", serde_json::to_string(&update).unwrap()));
520530

521-
let email = matches.value_of("email").unwrap();
522-
// TODO: "Please enter password" and quiet param
523-
let password = rpassword::read_password().context("Failed to read password")?;
524-
525-
core.authenticate("vscode_plugin", email.to_string(), password)
526-
.context("Failed to authenticate with TMC")?;
527-
528-
if let Some(_matches) = matches.subcommand_matches("get-organizations") {
531+
// set token if a credentials.json is found for the client name
532+
let client_name = matches.value_of("clientName").unwrap();
533+
let tmc_dir = format!("tmc-{}", client_name);
534+
535+
let config_dir = match env::var("TMC_LANGS_CLI_CONFIG_DIR") {
536+
Ok(v) => PathBuf::from(v),
537+
Err(_) => dirs::config_dir().context("Failed to find config directory")?,
538+
};
539+
let credentials_path = config_dir.join(tmc_dir).join("credentials.json");
540+
if let Ok(file) = File::open(&credentials_path) {
541+
let token: Token = serde_json::from_reader(file).expect("malformed credentials.json");
542+
core.set_token(token);
543+
};
544+
545+
if let Some(matches) = matches.subcommand_matches("login") {
546+
let email = matches.value_of("email").unwrap();
547+
// TODO: "Please enter password" and quiet param
548+
let password = rpassword::read_password().context("Failed to read password")?;
549+
let token = core
550+
.authenticate(client_name, email.to_string(), password)
551+
.context("Failed to authenticate with TMC")?;
552+
if let Some(p) = credentials_path.parent() {
553+
fs::create_dir_all(p)
554+
.with_context(|| format!("Failed to create directory {}", p.display()))?;
555+
}
556+
let credentials_file = File::create(&credentials_path).with_context(|| {
557+
format!("Failed to create file at {}", credentials_path.display())
558+
})?;
559+
serde_json::to_writer(credentials_file, &token).with_context(|| {
560+
format!(
561+
"Failed to write credentials to {}",
562+
credentials_path.display()
563+
)
564+
})?;
565+
} else if let Some(_matches) = matches.subcommand_matches("logout") {
566+
if credentials_path.exists() {
567+
fs::remove_file(&credentials_path).with_context(|| {
568+
format!(
569+
"Failed to remove credentials at {}",
570+
credentials_path.display()
571+
)
572+
})?;
573+
}
574+
} else if let Some(_matches) = matches.subcommand_matches("get-organizations") {
529575
let orgs = core
530576
.get_organizations()
531577
.context("Failed to get organizations")?;
@@ -627,11 +673,15 @@ fn run() -> Result<()> {
627673
let submission_path = matches.value_of("submissionPath").unwrap();
628674
let submission_path = Path::new(submission_path);
629675

630-
let locale = matches.value_of("locale").unwrap();
631-
let locale = into_locale(locale)?;
676+
let optional_locale = matches.value_of("locale");
677+
let optional_locale = if let Some(locale) = optional_locale {
678+
Some(into_locale(locale)?)
679+
} else {
680+
None
681+
};
632682

633683
let new_submission = core
634-
.submit(submission_url, submission_path, locale)
684+
.submit(submission_url, submission_path, optional_locale)
635685
.context("Failed to submit")?;
636686

637687
print_result_as_json(&new_submission)?;

tmc-langs-cli/tests/core_mock.rs

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,37 @@
1-
use mockito::{mock, Mock};
1+
use mockito::mock;
22
use std::env;
3-
use std::io::Write;
43
use std::process::Stdio;
54
use std::process::{Command, Output};
65
use tmc_langs_core::Organization;
76

8-
fn init() -> (Mock, Mock) {
7+
fn init() {
98
if env::var("RUST_LOG").is_err() {
109
env::set_var("RUST_LOG", "debug,hyper=warn,tokio_reactor=warn");
1110
}
1211
let _ = env_logger::builder().is_test(true).try_init();
1312
env::set_var("TMC_CORE_CLI_ROOT_URL", mockito::server_url());
14-
15-
let m1 = mock("GET", "/api/v8/application/vscode_plugin/credentials")
16-
.with_body(
17-
serde_json::json!({
18-
"application_id": "id",
19-
"secret": "secret",
20-
})
21-
.to_string(),
22-
)
23-
.create();
24-
let m2 = mock("POST", "/oauth/token")
25-
.with_body(
26-
serde_json::json!({
27-
"access_token": "token",
28-
"token_type": "bearer",
29-
})
30-
.to_string(),
31-
)
32-
.create();
33-
(m1, m2)
13+
env::set_var("TMC_CORE_CLI_CONFIG_DIR", "./");
3414
}
3515

3616
fn run_cmd(args: &[&str]) -> Output {
17+
let temp = tempfile::tempdir().unwrap();
18+
std::fs::create_dir_all(temp.path().join("client")).unwrap();
19+
let f = std::fs::File::create(temp.path().join("client").join("credentials.json")).unwrap();
20+
serde_json::to_writer(
21+
f,
22+
&serde_json::json! {
23+
{"access_token":"accesstoken","token_type":"bearer","scope":"public"}
24+
},
25+
)
26+
.unwrap();
3727
let path = env!("CARGO_BIN_EXE_tmc-langs-cli");
38-
let mut child = Command::new(path)
28+
let out = Command::new(path)
29+
.current_dir(temp.path())
3930
.stdout(Stdio::piped())
4031
.stdin(Stdio::piped())
4132
.args(args)
42-
.spawn()
33+
.output()
4334
.unwrap();
44-
let child_stdin = child.stdin.as_mut().unwrap();
45-
child_stdin.write_all("password\n".as_bytes()).unwrap();
46-
let out = child.wait_with_output().unwrap();
4735

4836
log::debug!("stdout: {}", String::from_utf8_lossy(&out.stdout));
4937
log::debug!("stderr: {}", String::from_utf8_lossy(&out.stderr));
@@ -67,7 +55,7 @@ fn get_organizations() {
6755
.to_string(),
6856
)
6957
.create();
70-
let out = run_cmd(&["core", "--email", "email", "get-organizations"]);
58+
let out = run_cmd(&["core", "--clientName", "client", "get-organizations"]);
7159
assert!(out.status.success());
7260
let out = String::from_utf8(out.stdout).unwrap();
7361
let orgs: Vec<Organization> = serde_json::from_str(&out).unwrap();

tmc-langs-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ pub use response::{
2222
SubmissionFeedbackResponse, SubmissionFinished, SubmissionProcessingStatus, SubmissionStatus,
2323
User,
2424
};
25-
pub use tmc_core::TmcCore;
25+
pub use tmc_core::{TmcCore, Token};
2626
pub use tmc_langs_util::{Language, RunResult, Strategy, ValidationResult};

tmc-langs-core/src/tmc_core.rs

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ pub type Token =
2323
oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
2424

2525
#[derive(Debug, Serialize)]
26-
pub enum StatusUpdate {
27-
Processing(&'static str, f64),
28-
Finished,
26+
pub struct StatusUpdate {
27+
finished: bool,
28+
message: &'static str,
29+
percent_done: f64,
2930
}
3031

3132
/// A struct for interacting with the TestMyCode service, including authentication
@@ -92,23 +93,35 @@ impl TmcCore {
9293
Self::new(config_dir, root_url)
9394
}
9495

96+
pub fn set_token(&mut self, token: Token) {
97+
self.token = Some(token);
98+
}
99+
95100
pub fn set_progress_report<F>(&mut self, progress_report: F)
96101
where
97102
F: Fn(StatusUpdate) + 'static,
98103
{
99104
self.progress_report = Some(Box::new(progress_report));
100105
}
101106

102-
pub fn report_progress(&self, msg: &'static str, progress: f64) {
103-
self.progress_report
104-
.as_ref()
105-
.map(|f| f(StatusUpdate::Processing(msg, progress)));
107+
pub fn report_progress(&self, message: &'static str, percent_done: f64) {
108+
self.progress_report.as_ref().map(|f| {
109+
f(StatusUpdate {
110+
finished: false,
111+
message,
112+
percent_done,
113+
})
114+
});
106115
}
107116

108-
pub fn report_complete(&self) {
109-
self.progress_report
110-
.as_ref()
111-
.map(|f| f(StatusUpdate::Finished));
117+
pub fn report_complete(&self, message: &'static str) {
118+
self.progress_report.as_ref().map(|f| {
119+
f(StatusUpdate {
120+
finished: true,
121+
message,
122+
percent_done: 1.0,
123+
})
124+
});
112125
}
113126

114127
/// Attempts to log in with the given credentials, returns an error if an authentication token is already present.
@@ -131,7 +144,7 @@ impl TmcCore {
131144
client_name: &str,
132145
email: String,
133146
password: String,
134-
) -> Result<()> {
147+
) -> Result<Token> {
135148
if self.token.is_some() {
136149
return Err(CoreError::AlreadyAuthenticated);
137150
}
@@ -161,9 +174,9 @@ impl TmcCore {
161174
&ResourceOwnerPassword::new(password),
162175
)
163176
.request(oauth2::reqwest::http_client)?;
164-
self.token = Some(token);
177+
self.token = Some(token.clone());
165178
log::debug!("authenticated");
166-
Ok(())
179+
Ok(token)
167180
}
168181

169182
/// Fetches all organizations.
@@ -247,6 +260,9 @@ impl TmcCore {
247260
/// let courses = core.list_courses("hy").unwrap();
248261
/// ```
249262
pub fn list_courses(&self, organization_slug: &str) -> Result<Vec<Course>> {
263+
if self.token.is_none() {
264+
return Err(CoreError::AuthRequired);
265+
}
250266
self.organization_courses(organization_slug)
251267
}
252268

@@ -285,7 +301,7 @@ impl TmcCore {
285301
file.write_all(&compressed)
286302
.map_err(|e| CoreError::FileWrite(file.path().to_path_buf(), e))?;
287303

288-
self.post_submission_to_paste(submission_url, file.path(), paste_message, locale)
304+
self.post_submission_to_paste(submission_url, file.path(), paste_message, Some(locale))
289305
}
290306

291307
/// Checks the coding style for the project.
@@ -354,7 +370,7 @@ impl TmcCore {
354370
&self,
355371
submission_url: Url,
356372
submission_path: &Path,
357-
locale: Language,
373+
locale: Option<Language>,
358374
) -> Result<NewSubmission> {
359375
// compress
360376
self.report_progress("Submitting exercise. Compressing submission...", 0.0);
@@ -367,7 +383,7 @@ impl TmcCore {
367383
self.report_progress("Wrote compressed data. Posting submission...", 0.75);
368384

369385
let result = self.post_submission(submission_url, file.path(), locale);
370-
self.report_complete();
386+
self.report_complete("Submission finished!");
371387
result
372388
}
373389

@@ -450,7 +466,12 @@ impl TmcCore {
450466
file.write_all(&compressed)
451467
.map_err(|e| CoreError::FileWrite(file.path().to_path_buf(), e))?;
452468

453-
self.post_submission_for_review(submission_url, file.path(), message_for_reviewer, locale)
469+
self.post_submission_for_review(
470+
submission_url,
471+
file.path(),
472+
message_for_reviewer,
473+
Some(locale),
474+
)
454475
}
455476

456477
/// Downloads the model solution from the given url.
@@ -738,7 +759,7 @@ mod test {
738759
.submit(
739760
submission_url,
740761
Path::new("tests/data/exercise"),
741-
Language::from_639_3("eng").unwrap(),
762+
Some(Language::Eng),
742763
)
743764
.unwrap();
744765
assert_eq!(
@@ -1024,4 +1045,26 @@ mod test {
10241045
SubmissionProcessingStatus::Processing(_) => panic!("wrong status"),
10251046
}
10261047
}
1048+
1049+
#[test]
1050+
fn status_serde() {
1051+
let p = StatusUpdate {
1052+
finished: false,
1053+
message: "submitting...",
1054+
percent_done: 0.5,
1055+
};
1056+
assert_eq!(
1057+
r#"{"finished":false,"message":"submitting...","percent_done":0.5}"#,
1058+
serde_json::to_string(&p).unwrap()
1059+
);
1060+
let f = StatusUpdate {
1061+
finished: true,
1062+
message: "done",
1063+
percent_done: 1.0,
1064+
};
1065+
assert_eq!(
1066+
r#"{"finished":true,"message":"done","percent_done":1.0}"#,
1067+
serde_json::to_string(&f).unwrap()
1068+
);
1069+
}
10271070
}

0 commit comments

Comments
 (0)